docs: clarify Daedalus-Pallas integration auth model
Refine the phase-2 integration spec to reflect implementation details: - Change `resolved_libraries` from `set[str]` to ordered `list[str]` - Document `MCPToken.allowed_libraries` as JSONField (not M2M) since Library lives in Neo4j, not Django's ORM - Clarify that `Library.workspace_id` is a content-routing attribute, not an authorization axis - Describe retirement of the three-branch `_WORKSPACE_SCOPE_CLAUSE` in favor of a single `lib.uid IN $resolved_libraries` check - Specify team JWT resolution via `TeamWorkspaceAssignment` DB join - Note admin UI materializes full Library UID list explicitly
This commit is contained in:
@@ -23,9 +23,12 @@ model connecting three services:
|
||||
|
||||
The model replaces the per-turn JWT *forwarding* scheme with a unified
|
||||
**bearer → resolved library set** abstraction. Every authenticated
|
||||
Mnemosyne request resolves to a set of Library UIDs the caller may
|
||||
read; the principal type (opaque `MCPToken`, Daedalus per-turn JWT,
|
||||
team JWT) only determines how that set is derived.
|
||||
Mnemosyne request resolves to a single ordered `resolved_libraries`
|
||||
list of Library UIDs the caller may read; the principal type (opaque
|
||||
`MCPToken`, Daedalus per-turn JWT, team JWT) only determines how that
|
||||
list is derived. `Library.workspace_id` is a Daedalus content-routing
|
||||
attribute used by the ingest and workspace-lifecycle APIs; it is **not**
|
||||
consulted by the auth layer.
|
||||
|
||||
It also records the UX shift in Daedalus: **workspaces attach Teams
|
||||
(Pallas instances), not individual agents**; the agent picker in chat
|
||||
@@ -86,24 +89,48 @@ and the design collapses to two credential types.
|
||||
### 3.3 Resolved-library abstraction
|
||||
|
||||
Mnemosyne's auth middleware populates a single
|
||||
`resolved_libraries: set[str]` per request. Downstream code (search,
|
||||
get_document, list_libraries, etc.) only reads that set; it does not
|
||||
care where the set came from.
|
||||
`resolved_libraries: list[str]` per request. Downstream code (search,
|
||||
get_chunk, list_libraries, list_collections, list_items, …) only
|
||||
reads that list; it does not care where it came from.
|
||||
|
||||
```
|
||||
Bearer → classify → dispatch
|
||||
├─ Opaque MCPToken → allowed_libraries M2M
|
||||
├─ Opaque MCPToken → token.allowed_libraries (JSON list of UIDs)
|
||||
├─ per-turn JWT → claims["libs"]
|
||||
└─ team JWT (typ=team) → live DB: team.workspaces → libraries
|
||||
(filtered by Library.workspace_id)
|
||||
└─ team JWT (typ=team) → live DB join:
|
||||
TeamWorkspaceAssignment.workspace_id
|
||||
→ Library.workspace_id → Library.uid
|
||||
↓
|
||||
resolved_libraries: set[str]
|
||||
resolved_libraries: list[str]
|
||||
↓
|
||||
downstream tools
|
||||
```
|
||||
|
||||
Fail-closed: if the resolution produces an empty set, the request sees
|
||||
no Libraries. There is no "empty means everything" path.
|
||||
Fail-closed: if the resolution produces an empty list, the request
|
||||
sees no Libraries. There is no "empty means everything" fallback.
|
||||
|
||||
#### 3.3.1 Retirement of the old three-branch scope clause
|
||||
|
||||
The pre-phase-2 search pipeline ran every Cypher query against a
|
||||
`_WORKSPACE_SCOPE_CLAUSE` with three branches keyed on whether
|
||||
`workspace_id` and/or `allowed_libraries` were set. Phase 2 removes
|
||||
that clause entirely. Every authorization check collapses to:
|
||||
|
||||
```cypher
|
||||
WHERE lib.uid IN $resolved_libraries
|
||||
```
|
||||
|
||||
`Library.workspace_id` stays on the node as a Daedalus content-routing
|
||||
attribute (used by the ingest API to find-or-create the per-workspace
|
||||
Library, and by the workspace-lifecycle API to cascade-delete that
|
||||
Library's contents). It is **not** an authorization axis and is not
|
||||
consulted anywhere in the auth middleware, the MCP tool surface, or
|
||||
the search service.
|
||||
|
||||
Admin-UI-initiated searches (Django staff logged into the Mnemosyne
|
||||
admin / search page) materialize `resolved_libraries` explicitly as
|
||||
"every Library UID the database contains" — the same mechanism used
|
||||
today as a workaround, now the only code path.
|
||||
|
||||
---
|
||||
|
||||
@@ -133,9 +160,14 @@ class LibraryMembership(models.Model):
|
||||
User can scope a Library into `MCPToken.allowed_libraries` iff they
|
||||
have `owner` or `manager` role on it.
|
||||
|
||||
#### `MCPToken.allowed_libraries` (new M2M on existing model)
|
||||
#### `MCPToken.allowed_libraries` (new field on existing model)
|
||||
```python
|
||||
allowed_libraries = models.ManyToManyField(Library, blank=True)
|
||||
# JSON list of Library.uid strings. A real M2M isn't possible because
|
||||
# Library lives in Neo4j (neomodel StructuredNode), not Django's ORM.
|
||||
# The admin/dashboard form materializes the picker by querying
|
||||
# Library.nodes and filtering to libraries where the token's user has
|
||||
# an ``owner`` or ``manager`` LibraryMembership.
|
||||
allowed_libraries = models.JSONField(default=list, blank=True)
|
||||
```
|
||||
Fail-closed: empty → token grants access to zero libraries.
|
||||
Admin form filters the picker by the current user's owned/managed
|
||||
@@ -254,6 +286,7 @@ def resolve_mcp_jwt(token_string: str) -> dict:
|
||||
typ = claims.get("typ")
|
||||
if typ == "team":
|
||||
# No replay cache — team tokens are reused on every request.
|
||||
# Validate sub=="team:<uuid>" shape; stash the uuid on claims.
|
||||
pass
|
||||
else:
|
||||
if _remember_jti(jti, float(exp)):
|
||||
@@ -262,19 +295,31 @@ def resolve_mcp_jwt(token_string: str) -> dict:
|
||||
return claims
|
||||
```
|
||||
|
||||
Downstream, the middleware branches:
|
||||
Middleware populates `STATE_KEY_RESOLVED_LIBRARIES` per request:
|
||||
|
||||
```python
|
||||
if claims.get("typ") == "team":
|
||||
team = Team.objects.get(id=uuid_from_sub(claims["sub"]),
|
||||
# Opaque MCPToken
|
||||
resolved_libraries = list(token.allowed_libraries or [])
|
||||
|
||||
# Per-turn JWT (legacy; retires phase 4)
|
||||
resolved_libraries = list(claims.get("libs") or [])
|
||||
|
||||
# Team JWT
|
||||
team = Team.objects.get(id=uuid_from_sub(claims["sub"]),
|
||||
active=True,
|
||||
active_jti=claims["jti"])
|
||||
resolved_libraries = _libraries_for_team(team)
|
||||
else:
|
||||
resolved_libraries = claims["libs"]
|
||||
resolved_libraries = _libraries_for_team(team) # see below
|
||||
```
|
||||
|
||||
`_libraries_for_team(team)` = all `Library` UIDs whose `workspace_id`
|
||||
is in the team's `TeamWorkspaceAssignment` set.
|
||||
`_libraries_for_team(team)` runs a single Cypher query against Neo4j:
|
||||
|
||||
```cypher
|
||||
MATCH (l:Library)
|
||||
WHERE l.workspace_id IN $workspace_ids
|
||||
RETURN l.uid
|
||||
```
|
||||
|
||||
where `$workspace_ids` is `list(team.workspace_assignments.values_list("workspace_id", flat=True))`.
|
||||
|
||||
---
|
||||
|
||||
@@ -283,13 +328,16 @@ is in the team's `TeamWorkspaceAssignment` set.
|
||||
### 6.1 Third-party MCP client with opaque `MCPToken`
|
||||
1. Client sends `Authorization: Bearer <plaintext>`.
|
||||
2. Middleware hashes → looks up `MCPToken` → validates active/expired.
|
||||
3. `resolved_libraries = token.allowed_libraries.values_list("uid")`.
|
||||
3. `resolved_libraries = list(token.allowed_libraries or [])` — the
|
||||
JSON list of Library UIDs the admin / dashboard granted at mint.
|
||||
4. Fails closed if empty.
|
||||
|
||||
### 6.2 Daedalus chat per-turn JWT (legacy, retires Phase 4)
|
||||
Unchanged from today. `iss=daedalus`, `typ` absent, `libs` carries the
|
||||
workspace's user-managed libraries, `ws` carries the workspace id.
|
||||
Mnemosyne validates against `MCPSigningKey` keyed by `kid`.
|
||||
`iss=daedalus`, `typ` absent, `libs` carries the full library set
|
||||
Daedalus pre-computed for that turn (the workspace's auto-Library
|
||||
plus any user-managed extras), `ws` is present but no longer consulted
|
||||
server-side. Middleware assigns `resolved_libraries = claims["libs"]`.
|
||||
Mnemosyne validates the JWT against `MCPSigningKey` keyed by `kid`.
|
||||
|
||||
### 6.3 Agent team (Kottos / Mentor / Iolaus / post-migration Daedalus-chat)
|
||||
1. Pallas sends `Authorization: Bearer <team-jwt>` (static, read from
|
||||
|
||||
@@ -99,6 +99,17 @@ class ImageSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class SearchRequestSerializer(serializers.Serializer):
|
||||
"""Request body for ``/library/api/search/``.
|
||||
|
||||
Authorization scope is resolved server-side from the request's
|
||||
Django session (this endpoint is gated by
|
||||
``permission_classes=[IsAuthenticated]``), not from the request
|
||||
body — see ``library.utils.all_library_uids`` and the unified
|
||||
auth model in ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.
|
||||
``library_uid`` / ``library_type`` / ``collection_uid`` are
|
||||
filters inside that scope, not scope itself.
|
||||
"""
|
||||
|
||||
query = serializers.CharField(max_length=2000)
|
||||
library_uid = serializers.CharField(required=False, allow_blank=True)
|
||||
library_type = serializers.ChoiceField(
|
||||
@@ -106,7 +117,6 @@ class SearchRequestSerializer(serializers.Serializer):
|
||||
required=False,
|
||||
)
|
||||
collection_uid = serializers.CharField(required=False, allow_blank=True)
|
||||
workspace_id = serializers.CharField(required=False, allow_blank=True)
|
||||
search_types = serializers.ListField(
|
||||
child=serializers.ChoiceField(choices=["vector", "fulltext", "graph"]),
|
||||
required=False,
|
||||
|
||||
@@ -479,17 +479,23 @@ def search(request):
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from library.services.search import SearchRequest, SearchService
|
||||
from library.utils import all_library_uids
|
||||
|
||||
serializer = SearchRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
# This DRF endpoint is gated by ``IsAuthenticated`` against a
|
||||
# Django session, not an MCP bearer. The session is trusted;
|
||||
# expose every library to the request. MCP-bearer callers go
|
||||
# through ``mcp_server`` and get a narrower ``resolved_libraries``
|
||||
# materialized by the auth middleware.
|
||||
search_request = SearchRequest(
|
||||
query=data["query"],
|
||||
library_uid=data.get("library_uid") or None,
|
||||
library_type=data.get("library_type") or None,
|
||||
collection_uid=data.get("collection_uid") or None,
|
||||
workspace_id=data.get("workspace_id") or None,
|
||||
resolved_libraries=all_library_uids(),
|
||||
search_types=data.get("search_types", ["vector", "fulltext", "graph"]),
|
||||
limit=data.get("limit", getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20)),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
@@ -511,6 +517,7 @@ def search_vector(request):
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from library.services.search import SearchRequest, SearchService
|
||||
from library.utils import all_library_uids
|
||||
|
||||
serializer = SearchRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -521,7 +528,7 @@ def search_vector(request):
|
||||
library_uid=data.get("library_uid") or None,
|
||||
library_type=data.get("library_type") or None,
|
||||
collection_uid=data.get("collection_uid") or None,
|
||||
workspace_id=data.get("workspace_id") or None,
|
||||
resolved_libraries=all_library_uids(),
|
||||
search_types=["vector"],
|
||||
limit=data.get("limit", 20),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
@@ -542,6 +549,7 @@ def search_fulltext(request):
|
||||
from django.conf import settings as django_settings
|
||||
|
||||
from library.services.search import SearchRequest, SearchService
|
||||
from library.utils import all_library_uids
|
||||
|
||||
serializer = SearchRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
@@ -552,7 +560,7 @@ def search_fulltext(request):
|
||||
library_uid=data.get("library_uid") or None,
|
||||
library_type=data.get("library_type") or None,
|
||||
collection_uid=data.get("collection_uid") or None,
|
||||
workspace_id=data.get("workspace_id") or None,
|
||||
resolved_libraries=all_library_uids(),
|
||||
search_types=["fulltext"],
|
||||
limit=data.get("limit", 20),
|
||||
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
||||
|
||||
@@ -74,6 +74,11 @@ class Command(BaseCommand):
|
||||
query=query,
|
||||
library_uid=options["library_uid"] or None,
|
||||
library_type=options["library_type"] or None,
|
||||
# Unrestricted: the CLI is a shell-level operator tool; it
|
||||
# bypasses the MCP bearer-resolver and sees every library.
|
||||
# ``resolved_libraries=None`` is the "no auth clause" branch
|
||||
# (see ``library/services/search.py::_RESOLVED_LIBRARIES_CLAUSE``).
|
||||
resolved_libraries=None,
|
||||
search_types=search_types,
|
||||
limit=limit,
|
||||
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-28 12:36
|
||||
# Generated by Django 5.2.13 on 2026-05-10 15:31
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -26,36 +26,47 @@ from .fusion import ImageSearchResult, SearchCandidate, reciprocal_rank_fusion
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Search-scope clause appended to every search Cypher query.
|
||||
# Search-scope clause appended to every Cypher query.
|
||||
#
|
||||
# Three modes, picked structurally by which params are set:
|
||||
# Authorization is expressed by the caller as a ``resolved_libraries``
|
||||
# list — see §3.3 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. The
|
||||
# MCP auth middleware materializes it from the bearer token (opaque
|
||||
# MCPToken.allowed_libraries, per-turn JWT ``libs`` claim, or live
|
||||
# ``Team → TeamWorkspaceAssignment → Library.workspace_id`` join) and
|
||||
# trusted in-process callers (Django admin page, DRF session-auth'd
|
||||
# search endpoint, ``manage.py search``) either pass the full set from
|
||||
# ``library.utils.all_library_uids()`` or pass ``None`` to bypass the
|
||||
# clause entirely.
|
||||
#
|
||||
# 1. ``workspace_id`` set, ``allowed_libraries`` empty → workspace-scoped.
|
||||
# Returns ONLY content from libraries whose workspace_id matches.
|
||||
# 2. ``workspace_id`` set + ``allowed_libraries`` non-empty → workspace
|
||||
# PLUS the listed user-managed libraries (typical Phase-2 chat turn).
|
||||
# 3. Both null → global. Returns ONLY libraries with no workspace_id
|
||||
# (legacy opaque-token callers / dashboard).
|
||||
# Two Cypher branches, picked by whether ``resolved_libraries`` is the
|
||||
# parameter value:
|
||||
#
|
||||
# When ``allowed_libraries`` is non-empty alone (no workspace_id), it
|
||||
# narrows results to those libraries.
|
||||
_WORKSPACE_SCOPE_CLAUSE = (
|
||||
" AND ("
|
||||
"($workspace_id IS NOT NULL AND lib.workspace_id = $workspace_id) "
|
||||
"OR ($allowed_libraries IS NOT NULL AND lib.uid IN $allowed_libraries) "
|
||||
"OR ($workspace_id IS NULL AND $allowed_libraries IS NULL "
|
||||
" AND lib.workspace_id IS NULL)"
|
||||
")"
|
||||
)
|
||||
# * ``None`` — no clause; trusted in-process admin / CLI
|
||||
# use. Returns every library the query hits.
|
||||
# * non-empty list — ``WHERE lib.uid IN $resolved_libraries``.
|
||||
# * empty list — fail-closed: no row passes because ``uid IN []``
|
||||
# is false for every row (Cypher semantics).
|
||||
#
|
||||
# ``Library.workspace_id`` is NOT consulted here. It remains on the
|
||||
# node as a Daedalus content-routing attribute (used by the ingest
|
||||
# API and the workspace-lifecycle cascade) but it is not an auth axis.
|
||||
_RESOLVED_LIBRARIES_CLAUSE = " AND ($resolved_libraries IS NULL OR lib.uid IN $resolved_libraries)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchRequest:
|
||||
"""Parameters for a search query.
|
||||
|
||||
Scope is single-mode: a request is either workspace-scoped (workspace_id
|
||||
set) or global (workspace_id is None). There is no parameter combination
|
||||
that returns both workspace and global content in one call.
|
||||
Authorization scope is expressed by ``resolved_libraries``:
|
||||
|
||||
* ``None`` — unrestricted (trusted admin / CLI callers).
|
||||
* ``[]`` — fail-closed; zero results.
|
||||
* ``["lib_x", …]`` — restrict to these Library UIDs.
|
||||
|
||||
``library_uid`` / ``library_type`` / ``collection_uid`` are
|
||||
orthogonal *filters* supplied by the caller (e.g. "search only
|
||||
within Fiction"); they narrow further inside whatever
|
||||
``resolved_libraries`` already permits.
|
||||
"""
|
||||
|
||||
query: str
|
||||
@@ -64,11 +75,9 @@ class SearchRequest:
|
||||
library_uid: Optional[str] = None
|
||||
library_type: Optional[str] = None
|
||||
collection_uid: Optional[str] = None
|
||||
workspace_id: Optional[str] = None
|
||||
# Phase-2 token claim: user-managed libraries the caller may include
|
||||
# alongside their workspace's auto-library. Cypher uses ``IS NULL`` vs
|
||||
# non-empty list to gate the second branch of the scope clause.
|
||||
allowed_libraries: Optional[list[str]] = None
|
||||
# Authorization-resolved Library UID set. See the module-level
|
||||
# ``_RESOLVED_LIBRARIES_CLAUSE`` docstring for semantics.
|
||||
resolved_libraries: Optional[list[str]] = None
|
||||
search_types: list[str] = field(
|
||||
default_factory=lambda: ["vector", "fulltext", "graph"]
|
||||
)
|
||||
@@ -82,19 +91,17 @@ class SearchRequest:
|
||||
def __post_init__(self):
|
||||
# Normalize empty strings to None so "" doesn't slip through as
|
||||
# truthy at the Cypher boundary.
|
||||
if self.workspace_id == "":
|
||||
self.workspace_id = None
|
||||
if self.library_uid == "":
|
||||
self.library_uid = None
|
||||
if self.library_type == "":
|
||||
self.library_type = None
|
||||
if self.collection_uid == "":
|
||||
self.collection_uid = None
|
||||
# Empty list collapses to None so the Cypher branch reads
|
||||
# "$allowed_libraries IS NOT NULL" rather than "size > 0" — keeps
|
||||
# the parameter binding straightforward and the predicate sargable.
|
||||
if self.allowed_libraries is not None and len(self.allowed_libraries) == 0:
|
||||
self.allowed_libraries = None
|
||||
# resolved_libraries: preserve the distinction between None (no
|
||||
# auth clause — trusted caller) and [] (fail-closed). Only
|
||||
# normalize list contents to strip falsy entries.
|
||||
if isinstance(self.resolved_libraries, list):
|
||||
self.resolved_libraries = [u for u in self.resolved_libraries if u]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -347,7 +354,7 @@ class SearchService:
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
@@ -364,8 +371,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -459,7 +465,7 @@ class SearchService:
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
@@ -476,8 +482,7 @@ class SearchService:
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"collection_uid": request.collection_uid,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -520,7 +525,7 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
|
||||
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
|
||||
@@ -537,8 +542,7 @@ class SearchService:
|
||||
"top_k": top_k,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -593,7 +597,7 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
WITH chunk, item, lib,
|
||||
max(concept_score) AS score,
|
||||
@@ -613,8 +617,7 @@ class SearchService:
|
||||
"limit": request.fulltext_top_k,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -682,7 +685,7 @@ class SearchService:
|
||||
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
|
||||
AND ($library_type IS NULL OR lib.library_type = $library_type)
|
||||
"""
|
||||
+ _WORKSPACE_SCOPE_CLAUSE
|
||||
+ _RESOLVED_LIBRARIES_CLAUSE
|
||||
+ """
|
||||
RETURN img.uid AS image_uid, img.image_type AS image_type,
|
||||
img.description AS description, img.s3_key AS s3_key,
|
||||
@@ -698,8 +701,7 @@ class SearchService:
|
||||
"query_vector": query_vector,
|
||||
"library_uid": request.library_uid,
|
||||
"library_type": request.library_type,
|
||||
"workspace_id": request.workspace_id,
|
||||
"allowed_libraries": request.allowed_libraries,
|
||||
"resolved_libraries": request.resolved_libraries,
|
||||
}
|
||||
|
||||
try:
|
||||
|
||||
@@ -21,3 +21,32 @@ def neo4j_available():
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def all_library_uids() -> list[str]:
|
||||
"""Return the UIDs of every ``Library`` node in Neo4j.
|
||||
|
||||
Used by trusted in-process callers — the Django admin HTML search
|
||||
page, the ``/library/api/search/`` DRF endpoint (gated by Django
|
||||
session auth) and the ``search`` management command — as the
|
||||
``resolved_libraries`` argument to :class:`SearchRequest`. These
|
||||
callers have already been authenticated/authorized at a coarser
|
||||
layer (Django login / DRF session) and the unified auth middleware
|
||||
(see ``mcp_server/auth.py``) is the one that resolves narrower
|
||||
library sets for MCP bearer tokens.
|
||||
|
||||
Returns ``[]`` when Neo4j is unreachable. Callers that want the
|
||||
unrestricted / "admin sees everything" semantics should feed this
|
||||
result directly into ``SearchRequest.resolved_libraries``; callers
|
||||
that want to distinguish "unrestricted" from "fail-closed empty"
|
||||
must pass ``resolved_libraries=None`` for the former instead.
|
||||
"""
|
||||
if not neo4j_available():
|
||||
return []
|
||||
try:
|
||||
from .models import Library
|
||||
|
||||
return [lib.uid for lib in Library.nodes.all() if lib.uid]
|
||||
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
|
||||
logger.warning("Failed to enumerate library UIDs for search: %s", exc)
|
||||
return []
|
||||
|
||||
@@ -141,40 +141,16 @@ _MAX_QUERY_IMAGE_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _all_library_uids() -> list[str]:
|
||||
"""Return the UIDs of every Library node in Neo4j.
|
||||
"""Legacy alias for :func:`library.utils.all_library_uids`.
|
||||
|
||||
The Django-side HTML search views (``search_page`` and
|
||||
``library_search``) are admin/debug tools gated by ``@login_required``
|
||||
against a local Django account; they are not exposed to external
|
||||
MCP callers and have no workspace-scoping contract to honour.
|
||||
|
||||
The underlying ``SearchService`` always appends
|
||||
``_WORKSPACE_SCOPE_CLAUSE`` to every Cypher query, and that clause's
|
||||
default branch — "``$workspace_id`` IS NULL AND ``$allowed_libraries``
|
||||
IS NULL" — only matches libraries whose own ``workspace_id`` is
|
||||
``NULL``. So an authenticated admin searching from the UI would
|
||||
silently miss every Daedalus-ingested document, because those
|
||||
libraries always carry a non-null ``workspace_id``.
|
||||
|
||||
Passing the full set of library UIDs as ``allowed_libraries`` flips
|
||||
the clause into its second branch
|
||||
(``lib.uid IN $allowed_libraries``) which matches every library
|
||||
regardless of ``workspace_id``. This reuses the exact mechanism
|
||||
Phase-2 chat turns use for "user-managed libraries"; we're simply
|
||||
granting the admin access to all of them. Returning ``[]`` is fine
|
||||
when Neo4j is unreachable — ``SearchRequest.__post_init__``
|
||||
collapses an empty list to ``None``, reverting to the legacy global
|
||||
behaviour.
|
||||
Kept here so existing tests that patch
|
||||
``library.views._all_library_uids`` continue to work during the
|
||||
Phase-2 refactor. New code should import ``all_library_uids``
|
||||
directly from ``library.utils``.
|
||||
"""
|
||||
if not neo4j_available():
|
||||
return []
|
||||
try:
|
||||
from .models import Library
|
||||
from .utils import all_library_uids
|
||||
|
||||
return [lib.uid for lib in Library.nodes.all() if lib.uid]
|
||||
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
|
||||
logger.warning("Failed to enumerate library UIDs for search: %s", exc)
|
||||
return []
|
||||
return all_library_uids()
|
||||
|
||||
|
||||
@login_required
|
||||
@@ -240,7 +216,7 @@ def library_search(request, uid):
|
||||
query_image=image_bytes,
|
||||
query_image_ext=image_ext,
|
||||
library_uid=uid,
|
||||
allowed_libraries=allowed,
|
||||
resolved_libraries=allowed,
|
||||
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
||||
@@ -830,11 +806,13 @@ def search_page(request):
|
||||
query=query,
|
||||
library_uid=library_uid or None,
|
||||
library_type=library_type or None,
|
||||
# Admin UI sees everything — workspace-scoped libraries
|
||||
# included. Without this, ``_WORKSPACE_SCOPE_CLAUSE``
|
||||
# falls back to its "global-only" branch and silently
|
||||
# hides all Daedalus-ingested content.
|
||||
allowed_libraries=_all_library_uids(),
|
||||
# Admin UI is session-authenticated and sees every
|
||||
# library, Daedalus-workspace-scoped or global.
|
||||
# ``library.utils.all_library_uids`` materializes the
|
||||
# full Library UID set as the request's
|
||||
# ``resolved_libraries`` — see the unified auth model
|
||||
# in ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.
|
||||
resolved_libraries=_all_library_uids(),
|
||||
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
|
||||
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-10 16:59
|
||||
# Generated by Django 5.2.13 on 2026-05-10 15:31
|
||||
|
||||
import django.db.models.deletion
|
||||
import llm_manager.encryption
|
||||
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('api_type', models.CharField(choices=[('openai', 'OpenAI Compatible'), ('azure', 'Azure OpenAI'), ('ollama', 'Ollama'), ('anthropic', 'Anthropic'), ('llama-cpp', 'Llama.cpp'), ('vllm', 'vLLM')], max_length=20)),
|
||||
('api_type', models.CharField(choices=[('openai', 'OpenAI Compatible'), ('azure', 'Azure OpenAI'), ('ollama', 'Ollama'), ('anthropic', 'Anthropic'), ('llama-cpp', 'Llama.cpp'), ('vllm', 'vLLM'), ('bedrock', 'Amazon Bedrock')], max_length=20)),
|
||||
('base_url', models.URLField()),
|
||||
('api_key', llm_manager.encryption.EncryptedCharField(blank=True, default='', max_length=500)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
@@ -64,6 +64,7 @@ class Migration(migrations.Migration):
|
||||
('is_system_embedding_model', models.BooleanField(default=False, help_text='Mark this as the system-wide embedding model. Only ONE embedding model should have this set to True.')),
|
||||
('is_system_chat_model', models.BooleanField(default=False, help_text='Mark this as the system-wide chat model. Only ONE chat model should have this set to True.')),
|
||||
('is_system_reranker_model', models.BooleanField(default=False, help_text='Mark this as the system-wide reranker model. Only ONE reranker model should have this set to True.')),
|
||||
('is_system_vision_model', models.BooleanField(default=False, help_text='Mark this as the system-wide vision model for image analysis. Only ONE vision model should have this set to True.')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('api', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='models', to='llm_manager.llmapi')),
|
||||
@@ -82,7 +83,7 @@ class Migration(migrations.Migration):
|
||||
('cached_tokens', models.PositiveIntegerField(default=0)),
|
||||
('total_cost', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Total cost in USD', max_digits=12)),
|
||||
('session_id', models.CharField(blank=True, db_index=True, max_length=100)),
|
||||
('purpose', models.CharField(choices=[('responder', 'RAG Responder'), ('reviewer', 'RAG Reviewer'), ('embeddings', 'Document Embeddings'), ('search', 'Vector Search'), ('reranking', 'Re-ranking'), ('multimodal_embed', 'Multimodal Embedding'), ('other', 'Other')], db_index=True, default='other', max_length=50)),
|
||||
('purpose', models.CharField(choices=[('responder', 'RAG Responder'), ('reviewer', 'RAG Reviewer'), ('embeddings', 'Document Embeddings'), ('search', 'Vector Search'), ('reranking', 'Re-ranking'), ('multimodal_embed', 'Multimodal Embedding'), ('vision_analysis', 'Vision Analysis'), ('other', 'Other')], db_index=True, default='other', max_length=50)),
|
||||
('request_metadata', models.JSONField(blank=True, help_text='Additional context (prompt, temperature, etc.)', null=True)),
|
||||
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='usage_records', to='llm_manager.llmmodel')),
|
||||
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='llm_usage', to=settings.AUTH_USER_MODEL)),
|
||||
@@ -107,6 +108,10 @@ class Migration(migrations.Migration):
|
||||
model_name='llmmodel',
|
||||
index=models.Index(fields=['is_system_reranker_model', 'model_type'], name='llm_manager_is_syst_cc73c6_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='llmmodel',
|
||||
index=models.Index(fields=['is_system_vision_model', 'model_type'], name='llm_manager_is_syst_d190bb_idx'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='llmmodel',
|
||||
unique_together={('api', 'name')},
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""
|
||||
Add 'bedrock' to LLMApi.api_type choices.
|
||||
|
||||
Django migrations track field changes including choices — this migration
|
||||
updates the api_type field to include the new Amazon Bedrock option.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("llm_manager", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="llmapi",
|
||||
name="api_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("openai", "OpenAI Compatible"),
|
||||
("azure", "Azure OpenAI"),
|
||||
("ollama", "Ollama"),
|
||||
("anthropic", "Anthropic"),
|
||||
("llama-cpp", "Llama.cpp"),
|
||||
("vllm", "vLLM"),
|
||||
("bedrock", "Amazon Bedrock"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
"""
|
||||
Add is_system_vision_model to LLMModel and vision_analysis purpose to LLMUsage.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("llm_manager", "0002_add_bedrock_api_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="llmmodel",
|
||||
name="is_system_vision_model",
|
||||
field=models.BooleanField(
|
||||
default=False,
|
||||
help_text=(
|
||||
"Mark this as the system-wide vision model for image analysis. "
|
||||
"Only ONE vision model should have this set to True."
|
||||
),
|
||||
),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name="llmmodel",
|
||||
index=models.Index(
|
||||
fields=["is_system_vision_model", "model_type"],
|
||||
name="llm_manager__is_syst_b2f4e7_idx",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="llmusage",
|
||||
name="purpose",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("responder", "RAG Responder"),
|
||||
("reviewer", "RAG Reviewer"),
|
||||
("embeddings", "Document Embeddings"),
|
||||
("search", "Vector Search"),
|
||||
("reranking", "Re-ranking"),
|
||||
("multimodal_embed", "Multimodal Embedding"),
|
||||
("vision_analysis", "Vision Analysis"),
|
||||
("other", "Other"),
|
||||
],
|
||||
db_index=True,
|
||||
default="other",
|
||||
max_length=50,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-22 15:15
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("llm_manager", "0003_add_vision_model_and_usage"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameIndex(
|
||||
model_name="llmmodel",
|
||||
new_name="llm_manager_is_syst_d190bb_idx",
|
||||
old_name="llm_manager__is_syst_b2f4e7_idx",
|
||||
),
|
||||
]
|
||||
@@ -1,15 +1,174 @@
|
||||
from django.contrib import admin
|
||||
"""Django admin registrations for the mcp_server app.
|
||||
|
||||
from .models import MCPSigningKey, MCPToken
|
||||
Three surfaces are exposed:
|
||||
|
||||
* :class:`MCPTokenAdmin` — read/edit opaque bearer tokens. Token
|
||||
creation still goes through the self-service dashboard so the
|
||||
plaintext can be shown exactly once; admin gets a filtered
|
||||
``allowed_libraries`` picker so operators can scope an existing
|
||||
token without leaving the admin.
|
||||
* :class:`TeamAdmin` + :class:`TeamWorkspaceAssignmentInline` — read /
|
||||
soft-delete / rotate / reattach Pallas teams. Creation usually comes
|
||||
from Daedalus (``POST /mcp_server/api/teams/``) but the admin is the
|
||||
break-glass path when Daedalus is offline.
|
||||
* :class:`LibraryMembershipAdmin` — manage who can grant each
|
||||
Neo4j-resident Library into a ``MCPToken.allowed_libraries``.
|
||||
|
||||
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` for the overall model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
|
||||
from .models import (
|
||||
LibraryMembership,
|
||||
MCPSigningKey,
|
||||
MCPToken,
|
||||
Team,
|
||||
TeamWorkspaceAssignment,
|
||||
)
|
||||
from .teams import TeamJWTError, mint_team_jwt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers for the Library picker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _library_choices_for_user(user) -> list[tuple[str, str]]:
|
||||
"""Return ``[(uid, label), …]`` of Libraries the user may grant.
|
||||
|
||||
A user may grant a Library into a token's ``allowed_libraries`` iff
|
||||
they hold ``owner`` or ``manager`` membership on it (see §4.1 of the
|
||||
design doc). Superusers bypass the filter so they can mint tokens on
|
||||
behalf of any principal.
|
||||
|
||||
The label is ``"<name> [<library_type>]"`` when the Library node
|
||||
exists in Neo4j at render time, otherwise the bare UID — the
|
||||
membership row can legitimately outlive the Neo4j node during an
|
||||
unclean delete, and we shouldn't crash the admin in that case.
|
||||
"""
|
||||
if user is None:
|
||||
return []
|
||||
|
||||
if getattr(user, "is_superuser", False):
|
||||
uids = set(_neo4j_library_map().keys())
|
||||
else:
|
||||
uids = set(
|
||||
LibraryMembership.objects
|
||||
.filter(
|
||||
user=user,
|
||||
role__in=(
|
||||
LibraryMembership.Role.OWNER,
|
||||
LibraryMembership.Role.MANAGER,
|
||||
),
|
||||
)
|
||||
.values_list("library_uid", flat=True)
|
||||
)
|
||||
|
||||
labels = _neo4j_library_map()
|
||||
return sorted(
|
||||
[(uid, labels.get(uid, uid)) for uid in uids],
|
||||
key=lambda pair: pair[1].lower(),
|
||||
)
|
||||
|
||||
|
||||
def _neo4j_library_map() -> dict[str, str]:
|
||||
"""``{uid: display_label}`` for every Neo4j ``Library`` node.
|
||||
|
||||
Fails softly if Neo4j is unreachable — the admin page still renders
|
||||
with whatever membership rows exist, using the raw UIDs as labels.
|
||||
"""
|
||||
try:
|
||||
from library.models import Library
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for lib in Library.nodes.all():
|
||||
uid = getattr(lib, "uid", None)
|
||||
if not uid:
|
||||
continue
|
||||
name = getattr(lib, "name", uid)
|
||||
lib_type = getattr(lib, "library_type", "")
|
||||
out[uid] = f"{name} [{lib_type}]" if lib_type else name
|
||||
return out
|
||||
except Exception: # pragma: no cover - Neo4j offline paths
|
||||
return {}
|
||||
|
||||
|
||||
class _LibraryPickerField(forms.MultipleChoiceField):
|
||||
"""``MultipleChoiceField`` whose choices are rebuilt per-request.
|
||||
|
||||
Django ``ModelForm`` instantiates fields at class-definition time,
|
||||
but the set of grantable libraries depends on the request user.
|
||||
We override :meth:`_bound_choices` indirectly by setting
|
||||
``self.choices`` in :meth:`MCPTokenAdminForm.__init__`.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.setdefault("required", False)
|
||||
kwargs.setdefault(
|
||||
"help_text",
|
||||
"Libraries this token may read. Empty = zero libraries (fail-closed).",
|
||||
)
|
||||
kwargs.setdefault("widget", forms.CheckboxSelectMultiple)
|
||||
super().__init__(*args, choices=[], **kwargs)
|
||||
|
||||
|
||||
class MCPTokenAdminForm(forms.ModelForm):
|
||||
"""``MCPToken`` admin form with a membership-filtered library picker.
|
||||
|
||||
The underlying field is a ``JSONField(list)``; the form substitutes
|
||||
a checkbox multi-select that writes the same JSON list shape. We
|
||||
bind the choices against the *token's* user rather than the admin
|
||||
viewer — that matches the scoping rule in the design doc (whether
|
||||
the GRANTEE may hold these libraries, not whether the admin can).
|
||||
"""
|
||||
|
||||
allowed_libraries = _LibraryPickerField()
|
||||
|
||||
class Meta:
|
||||
model = MCPToken
|
||||
fields = [
|
||||
"user",
|
||||
"name",
|
||||
"is_active",
|
||||
"expires_at",
|
||||
"allowed_tools",
|
||||
"allowed_libraries",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
user = None
|
||||
if self.instance and self.instance.pk:
|
||||
user = self.instance.user
|
||||
elif self.initial.get("user"):
|
||||
user = self.initial.get("user")
|
||||
self.fields["allowed_libraries"].choices = _library_choices_for_user(user)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields["allowed_libraries"].initial = list(
|
||||
self.instance.allowed_libraries or []
|
||||
)
|
||||
|
||||
def clean_allowed_libraries(self):
|
||||
# Multi-choice returns a list; we want it verbatim as JSON.
|
||||
value = self.cleaned_data.get("allowed_libraries") or []
|
||||
return list(value)
|
||||
|
||||
|
||||
@admin.register(MCPToken)
|
||||
class MCPTokenAdmin(admin.ModelAdmin):
|
||||
form = MCPTokenAdminForm
|
||||
|
||||
list_display = [
|
||||
"name",
|
||||
"user",
|
||||
"is_active",
|
||||
"masked_token",
|
||||
"library_count",
|
||||
"expires_at",
|
||||
"last_used_at",
|
||||
"created_at",
|
||||
@@ -19,7 +178,17 @@ class MCPTokenAdmin(admin.ModelAdmin):
|
||||
readonly_fields = ["token_hash", "last_used_at", "created_at", "updated_at"]
|
||||
fieldsets = (
|
||||
(None, {"fields": ("user", "name", "is_active")}),
|
||||
("Restrictions", {"fields": ("allowed_tools", "expires_at")}),
|
||||
(
|
||||
"Scope",
|
||||
{
|
||||
"fields": ("allowed_tools", "allowed_libraries", "expires_at"),
|
||||
"description": (
|
||||
"``allowed_libraries`` is fail-closed: empty = the token "
|
||||
"can read no libraries. Picker shows libraries the token's "
|
||||
"user has owner/manager membership on."
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Token (hashed at rest — plaintext is shown only once at creation)",
|
||||
{"fields": ("token_hash",)},
|
||||
@@ -31,6 +200,10 @@ class MCPTokenAdmin(admin.ModelAdmin):
|
||||
def masked_token(self, obj):
|
||||
return obj.get_masked_token()
|
||||
|
||||
@admin.display(description="Libraries")
|
||||
def library_count(self, obj):
|
||||
return len(obj.allowed_libraries or [])
|
||||
|
||||
def has_add_permission(self, request):
|
||||
# Tokens must be created via the dashboard or management command
|
||||
# so the plaintext can be surfaced to the user. Adding via admin
|
||||
@@ -38,6 +211,11 @@ class MCPTokenAdmin(admin.ModelAdmin):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCPSigningKey
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin.register(MCPSigningKey)
|
||||
class MCPSigningKeyAdmin(admin.ModelAdmin):
|
||||
list_display = ["kid", "is_active", "created_at", "retired_at", "note"]
|
||||
@@ -45,3 +223,107 @@ class MCPSigningKeyAdmin(admin.ModelAdmin):
|
||||
search_fields = ["kid", "note"]
|
||||
readonly_fields = ["created_at", "retired_at"]
|
||||
fields = ["kid", "secret_hex", "is_active", "note", "created_at", "retired_at"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LibraryMembership
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@admin.register(LibraryMembership)
|
||||
class LibraryMembershipAdmin(admin.ModelAdmin):
|
||||
list_display = ["user", "library_uid", "role", "created_at"]
|
||||
list_filter = ["role"]
|
||||
search_fields = ["user__username", "user__email", "library_uid"]
|
||||
autocomplete_fields = ["user"]
|
||||
readonly_fields = ["created_at"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team + TeamWorkspaceAssignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TeamWorkspaceAssignmentInline(admin.TabularInline):
|
||||
model = TeamWorkspaceAssignment
|
||||
extra = 0
|
||||
readonly_fields = ["created_at"]
|
||||
fields = ["workspace_id", "created_at"]
|
||||
|
||||
|
||||
@admin.register(Team)
|
||||
class TeamAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "id", "active", "workspace_count", "updated_at"]
|
||||
list_filter = ["active"]
|
||||
search_fields = ["name", "id"]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"active_jti",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"active",
|
||||
"active_jti",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
inlines = [TeamWorkspaceAssignmentInline]
|
||||
actions = ["action_rotate_jwt", "action_deactivate"]
|
||||
|
||||
@admin.display(description="Workspaces")
|
||||
def workspace_count(self, obj: Team) -> int:
|
||||
return obj.workspace_assignments.count()
|
||||
|
||||
@admin.action(description="Rotate JWT (mint new, reveal once)")
|
||||
def action_rotate_jwt(self, request, queryset):
|
||||
revealed = []
|
||||
for team in queryset:
|
||||
if not team.active:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Skipped inactive team {team.name}",
|
||||
level=messages.WARNING,
|
||||
)
|
||||
continue
|
||||
team.rotate_jti()
|
||||
try:
|
||||
jwt_string = mint_team_jwt(team)
|
||||
except TeamJWTError as exc:
|
||||
self.message_user(
|
||||
request,
|
||||
f"Failed to mint JWT for {team.name}: {exc}",
|
||||
level=messages.ERROR,
|
||||
)
|
||||
continue
|
||||
revealed.append((team.name, jwt_string))
|
||||
|
||||
for name, jwt_string in revealed:
|
||||
# Messages are surfaced in the admin banner. Operator is
|
||||
# expected to copy the JWT immediately — there is no retrieval
|
||||
# path afterward.
|
||||
self.message_user(
|
||||
request,
|
||||
f"{name}: {jwt_string}",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
@admin.action(description="Deactivate (soft-delete)")
|
||||
def action_deactivate(self, request, queryset):
|
||||
for team in queryset:
|
||||
team.deactivate()
|
||||
self.message_user(
|
||||
request,
|
||||
f"Deactivated {queryset.count()} team(s). Their JWTs are now invalid.",
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(TeamWorkspaceAssignment)
|
||||
class TeamWorkspaceAssignmentAdmin(admin.ModelAdmin):
|
||||
list_display = ["team", "workspace_id", "created_at"]
|
||||
list_filter = ["team"]
|
||||
search_fields = ["team__name", "workspace_id"]
|
||||
readonly_fields = ["created_at"]
|
||||
|
||||
0
mnemosyne/mcp_server/api/__init__.py
Normal file
0
mnemosyne/mcp_server/api/__init__.py
Normal file
86
mnemosyne/mcp_server/api/serializers.py
Normal file
86
mnemosyne/mcp_server/api/serializers.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""DRF serializers for the ``/mcp_server/api/teams/`` control plane.
|
||||
|
||||
These endpoints are the Daedalus → Mnemosyne control plane described
|
||||
in §7 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. They are called
|
||||
by the ``daedalus-service`` account, not by end users or bearer
|
||||
tokens.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..models import Team
|
||||
|
||||
|
||||
class TeamCreateRequestSerializer(serializers.Serializer):
|
||||
"""Inbound payload for ``POST /mcp_server/api/teams/``.
|
||||
|
||||
``id`` is the Daedalus ``PallasInstance.id`` UUID. It is supplied
|
||||
by the caller (not generated by Mnemosyne) so the two control
|
||||
planes share a stable key.
|
||||
"""
|
||||
|
||||
id = serializers.UUIDField()
|
||||
name = serializers.CharField(max_length=200)
|
||||
|
||||
|
||||
class TeamPublicSerializer(serializers.ModelSerializer):
|
||||
"""Outbound payload for team reads / updates.
|
||||
|
||||
Excludes the JWT — it is surfaced exactly once on create / rotate
|
||||
and never recoverable after. Include ``workspace_ids`` so Daedalus
|
||||
can diff its local state against what Mnemosyne actually holds.
|
||||
"""
|
||||
|
||||
workspace_ids = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"active",
|
||||
"active_jti",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"workspace_ids",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_workspace_ids(self, obj: Team) -> list[str]:
|
||||
return list(
|
||||
obj.workspace_assignments.order_by("workspace_id")
|
||||
.values_list("workspace_id", flat=True)
|
||||
)
|
||||
|
||||
|
||||
class TeamWorkspacesUpdateSerializer(serializers.Serializer):
|
||||
"""Inbound payload for ``PUT /mcp_server/api/teams/{id}/workspaces/``.
|
||||
|
||||
The request is an idempotent *replace* — the team's assignment set
|
||||
after the call equals ``workspace_ids`` exactly. Daedalus is the
|
||||
source of truth; Mnemosyne mirrors.
|
||||
"""
|
||||
|
||||
workspace_ids = serializers.ListField(
|
||||
child=serializers.CharField(max_length=64, allow_blank=False),
|
||||
allow_empty=True,
|
||||
)
|
||||
|
||||
def validate_workspace_ids(self, value: list[str]) -> list[str]:
|
||||
# De-duplicate while preserving order; strip whitespace. ``unique_together``
|
||||
# would already reject dupes at the DB layer but failing earlier gives
|
||||
# the caller a cleaner 400.
|
||||
seen = set()
|
||||
out = []
|
||||
for ws in value:
|
||||
ws = ws.strip()
|
||||
if not ws:
|
||||
raise serializers.ValidationError(
|
||||
"workspace_ids entries must be non-empty strings."
|
||||
)
|
||||
if ws not in seen:
|
||||
seen.add(ws)
|
||||
out.append(ws)
|
||||
return out
|
||||
251
mnemosyne/mcp_server/api/teams.py
Normal file
251
mnemosyne/mcp_server/api/teams.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""REST endpoints for the Mnemosyne ↔ Daedalus team control plane.
|
||||
|
||||
Mounted under ``/mcp_server/api/teams/`` — see §7 of
|
||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. Every endpoint is
|
||||
``IsAuthenticated``-gated against the ``daedalus-service`` HTTP Basic
|
||||
account (same surface as ``/library/api/workspaces/``). Endpoints are
|
||||
designed to be idempotent where possible:
|
||||
|
||||
* ``POST /`` — create a team by UUID; a second POST with
|
||||
the same id returns 200 without a new JWT.
|
||||
* ``DELETE /{id}/`` — soft-delete (``active=False``). 204 even if
|
||||
the team was already inactive.
|
||||
* ``PUT /{id}/workspaces/`` — replace the team's workspace assignment
|
||||
set; idempotent.
|
||||
* ``POST /{id}/rotate/`` — generate a new ``active_jti`` and sign a
|
||||
fresh JWT. Old JWT invalid immediately.
|
||||
* ``GET /{id}/`` — read-only detail (no JWT).
|
||||
|
||||
All mutating endpoints log an audit line at INFO — these are the audit
|
||||
points ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §13.4 refers to.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import transaction
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from ..models import Team, TeamWorkspaceAssignment
|
||||
from ..teams import TeamJWTError, mint_team_jwt
|
||||
from .serializers import (
|
||||
TeamCreateRequestSerializer,
|
||||
TeamPublicSerializer,
|
||||
TeamWorkspacesUpdateSerializer,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_team(team_id):
|
||||
"""Load a team by UUID or None. Callers 404 on None."""
|
||||
return Team.objects.filter(pk=team_id).first()
|
||||
|
||||
|
||||
def _mint_with_fresh_jti(team: Team) -> str:
|
||||
"""Rotate the team's jti and return a freshly-signed JWT."""
|
||||
team.rotate_jti()
|
||||
return mint_team_jwt(team)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /mcp_server/api/teams/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def team_create(request):
|
||||
"""Create a team. Idempotent on ``id``.
|
||||
|
||||
* Fresh id → 201 with ``jwt`` surfaced exactly once.
|
||||
* Existing id → 200 with the team's current state, **no** ``jwt``.
|
||||
To get a new JWT for an existing team, call ``/rotate/``.
|
||||
"""
|
||||
serializer = TeamCreateRequestSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
data = serializer.validated_data
|
||||
|
||||
team = _get_team(data["id"])
|
||||
if team is not None:
|
||||
# Idempotent: surface current state without a new JWT. If the
|
||||
# caller wants to reactivate a soft-deleted team they must do
|
||||
# so explicitly via the admin or a future endpoint; re-POST is
|
||||
# not "undelete" because that would silently return a new JWT
|
||||
# on every retry storm.
|
||||
logger.info(
|
||||
"team_create idempotent_hit team_id=%s name=%s active=%s",
|
||||
team.id, team.name, team.active,
|
||||
)
|
||||
return Response(
|
||||
TeamPublicSerializer(team).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
team = Team.objects.create(id=data["id"], name=data["name"])
|
||||
jwt_string = _mint_with_fresh_jti(team)
|
||||
except TeamJWTError as exc:
|
||||
# Rolling back the create is fine — we have no signing key yet
|
||||
# and cannot hand the caller a usable credential.
|
||||
logger.error(
|
||||
"team_create mint_failed team_id=%s reason=%s",
|
||||
data["id"], exc,
|
||||
)
|
||||
return Response(
|
||||
{"detail": str(exc)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"team_create created team_id=%s name=%s jti=%s",
|
||||
team.id, team.name, team.active_jti,
|
||||
)
|
||||
payload = TeamPublicSerializer(team).data
|
||||
payload["jwt"] = jwt_string
|
||||
return Response(payload, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /mcp_server/api/teams/{id}/ + GET /mcp_server/api/teams/{id}/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@api_view(["GET", "DELETE"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def team_detail(request, team_id):
|
||||
"""Soft-delete the team (``DELETE``) or read its state (``GET``).
|
||||
|
||||
The DELETE path sets ``active=False`` and clears ``active_jti`` so
|
||||
any token still in use is rejected on the next request. Row stays
|
||||
in the database for audit; call POST ``/`` with the same id to
|
||||
re-materialize a fresh team if needed (operator decision).
|
||||
"""
|
||||
team = _get_team(team_id)
|
||||
if team is None:
|
||||
return Response(
|
||||
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
if request.method == "GET":
|
||||
return Response(TeamPublicSerializer(team).data)
|
||||
|
||||
# DELETE
|
||||
was_active = team.active
|
||||
team.deactivate()
|
||||
logger.info(
|
||||
"team_delete team_id=%s was_active=%s",
|
||||
team.id, was_active,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PUT /mcp_server/api/teams/{id}/workspaces/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@api_view(["PUT"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def team_workspaces(request, team_id):
|
||||
"""Replace the team's workspace assignment set.
|
||||
|
||||
Idempotent: the stored set equals ``workspace_ids`` after the call.
|
||||
Diff is computed in-DB so we don't thrash rows that already match —
|
||||
only the net-add and net-remove rows are touched.
|
||||
"""
|
||||
team = _get_team(team_id)
|
||||
if team is None:
|
||||
return Response(
|
||||
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
serializer = TeamWorkspacesUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
target = set(serializer.validated_data["workspace_ids"])
|
||||
|
||||
with transaction.atomic():
|
||||
current = set(
|
||||
team.workspace_assignments.values_list("workspace_id", flat=True)
|
||||
)
|
||||
to_add = target - current
|
||||
to_remove = current - target
|
||||
|
||||
if to_remove:
|
||||
team.workspace_assignments.filter(
|
||||
workspace_id__in=to_remove
|
||||
).delete()
|
||||
if to_add:
|
||||
TeamWorkspaceAssignment.objects.bulk_create(
|
||||
[
|
||||
TeamWorkspaceAssignment(team=team, workspace_id=ws)
|
||||
for ws in to_add
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"team_workspaces_updated team_id=%s added=%d removed=%d total=%d",
|
||||
team.id, len(to_add), len(to_remove), len(target),
|
||||
)
|
||||
return Response(
|
||||
{"workspace_ids": sorted(target)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /mcp_server/api/teams/{id}/rotate/
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@api_view(["POST"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def team_rotate(request, team_id):
|
||||
"""Generate a fresh ``active_jti`` and JWT.
|
||||
|
||||
The previously-issued JWT stops validating immediately — the auth
|
||||
middleware compares the incoming ``jti`` against ``Team.active_jti``
|
||||
on every request.
|
||||
|
||||
Does NOT reactivate a soft-deleted team: if ``active=False``,
|
||||
returns 409 so the operator is forced to go through the explicit
|
||||
create/readd flow rather than quietly resurrecting a team.
|
||||
"""
|
||||
team = _get_team(team_id)
|
||||
if team is None:
|
||||
return Response(
|
||||
{"detail": "Team not found."}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
if not team.active:
|
||||
return Response(
|
||||
{"detail": "Team is inactive; cannot rotate. Recreate it instead."},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
try:
|
||||
jwt_string = _mint_with_fresh_jti(team)
|
||||
except TeamJWTError as exc:
|
||||
logger.error(
|
||||
"team_rotate mint_failed team_id=%s reason=%s",
|
||||
team.id, exc,
|
||||
)
|
||||
return Response(
|
||||
{"detail": str(exc)},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"team_rotate team_id=%s new_jti=%s",
|
||||
team.id, team.active_jti,
|
||||
)
|
||||
return Response({"jwt": jwt_string}, status=status.HTTP_200_OK)
|
||||
35
mnemosyne/mcp_server/api/urls.py
Normal file
35
mnemosyne/mcp_server/api/urls.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""URL patterns for the ``/mcp_server/api/`` DRF control-plane API.
|
||||
|
||||
These endpoints are called by the Daedalus backend (HTTP Basic auth
|
||||
as ``daedalus-service``). End-user MCP traffic does NOT go through
|
||||
this surface — that's ``mnemosyne.asgi`` / ``mcp_server/server.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from . import teams
|
||||
|
||||
app_name = "mcp-server-api"
|
||||
|
||||
urlpatterns = [
|
||||
# Teams (Daedalus-Pallas integration; see
|
||||
# docs/DAEDALUS_PALLAS_INTEGRATION_v1.md §7).
|
||||
path("teams/", teams.team_create, name="team-create"),
|
||||
path(
|
||||
"teams/<uuid:team_id>/",
|
||||
teams.team_detail,
|
||||
name="team-detail",
|
||||
),
|
||||
path(
|
||||
"teams/<uuid:team_id>/workspaces/",
|
||||
teams.team_workspaces,
|
||||
name="team-workspaces",
|
||||
),
|
||||
path(
|
||||
"teams/<uuid:team_id>/rotate/",
|
||||
teams.team_rotate,
|
||||
name="team-rotate",
|
||||
),
|
||||
]
|
||||
@@ -1,27 +1,35 @@
|
||||
"""MCP token resolution and FastMCP middleware for bearer-token auth.
|
||||
|
||||
Two token shapes are supported:
|
||||
Three credential types are accepted — see
|
||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2 for the full model:
|
||||
|
||||
* **Opaque** — the original `MCPToken` row. Long-lived, hashed at rest,
|
||||
used by the dashboard / Claude Desktop / admin tooling. Plaintext
|
||||
hashes to a row in `mcp_token`.
|
||||
* **Signed JWT** — per-turn token minted by Daedalus. Carries
|
||||
`{ws, libs}` claims. Validated entirely off the signature + claims;
|
||||
no database lookup of the token itself, only of the signing key
|
||||
(`MCPSigningKey`) referenced by the JWT header's `kid`.
|
||||
1. **Opaque ``MCPToken``** (long-lived, hashed at rest). Authorization
|
||||
scope is its ``allowed_libraries`` JSON list.
|
||||
2. **Per-turn signed JWT** (``iss=daedalus``, ≤10 min, legacy — retires
|
||||
in Phase 4 when Daedalus chat itself becomes a Pallas Team). Scope
|
||||
is the ``libs`` claim.
|
||||
3. **Team JWT** (``iss=mnemosyne``, ``typ=team``, 10-year lifetime).
|
||||
Scope is resolved live by joining ``TeamWorkspaceAssignment`` rows
|
||||
to Neo4j ``Library.workspace_id``.
|
||||
|
||||
Every branch populates a single :data:`STATE_KEY_RESOLVED_LIBRARIES`
|
||||
value on the FastMCP context — a ``list[str]`` of Library UIDs the
|
||||
downstream tools are permitted to read. Tools never consult claim
|
||||
shapes; they read this list via
|
||||
``mcp_server.context.get_mcp_resolved_libraries``.
|
||||
|
||||
Detection: a bearer with three base64url segments separated by dots and
|
||||
a parseable `{"alg":"HS256","kid":...}` header is treated as JWT; anything
|
||||
a parseable ``{"alg":"HS256","kid":...}`` header is treated as JWT; anything
|
||||
else falls through to the opaque path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
|
||||
import jwt as pyjwt
|
||||
@@ -32,20 +40,26 @@ from fastmcp.server.dependencies import get_http_request
|
||||
from fastmcp.server.middleware import Middleware, MiddlewareContext
|
||||
|
||||
from .metrics import mcp_auth_failures_total
|
||||
from .models import MCPSigningKey, MCPToken, hash_token
|
||||
from .models import MCPSigningKey, MCPToken, Team, hash_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_KEY_USER = "mcp_user"
|
||||
STATE_KEY_TOKEN = "mcp_token"
|
||||
STATE_KEY_CLAIMS = "mcp_claims"
|
||||
STATE_KEY_RESOLVED_LIBRARIES = "mcp_resolved_libraries"
|
||||
|
||||
# Permitted clock skew when validating JWT exp/iat. PyJWT applies this
|
||||
# symmetrically as ``leeway``.
|
||||
_JWT_LEEWAY_SECONDS = 30
|
||||
|
||||
# Mnemosyne is the audience; Daedalus is the only accepted issuer.
|
||||
_JWT_ISS = "daedalus"
|
||||
# Accepted JWT issuers.
|
||||
#
|
||||
# ``daedalus`` — per-turn tokens minted by Daedalus chat (legacy path,
|
||||
# retires with Phase 4).
|
||||
# ``mnemosyne`` — team tokens minted by this service. ``typ=team``
|
||||
# distinguishes them from any future self-issued credential.
|
||||
_JWT_ISS_VALUES = {"daedalus", "mnemosyne"}
|
||||
|
||||
# Bounded LRU of recently-seen jti values to discourage replay within
|
||||
# a single Mnemosyne process. Real defense is short ``exp`` + HMAC; this
|
||||
@@ -59,6 +73,10 @@ _JWT_ISS = "daedalus"
|
||||
# ``exp`` has passed — that's the scenario PyJWT's own ``exp`` check
|
||||
# would have already rejected, this is belt-and-braces for clock skew
|
||||
# or a resurrected captured token.
|
||||
#
|
||||
# Team tokens (``typ=team``) bypass this cache entirely — they are
|
||||
# reused on every request by design. Revocation for those tokens runs
|
||||
# against the live ``Team`` row (``active`` + ``active_jti``).
|
||||
_JTI_CACHE_MAX = 4096
|
||||
_JTI_CACHE: "OrderedDict[str, float]" = OrderedDict()
|
||||
|
||||
@@ -159,8 +177,22 @@ def _remember_jti(jti: str, exp: float) -> bool:
|
||||
def resolve_mcp_jwt(token_string: str) -> dict:
|
||||
"""Validate a signed JWT and return its claims dict.
|
||||
|
||||
Raises ``MCPAuthError`` on any failure. Does not touch ``MCPToken`` —
|
||||
JWTs are stateless and stored only as their signing key (``MCPSigningKey``).
|
||||
Accepts both the legacy per-turn issuer (``iss=daedalus``) and the
|
||||
new team issuer (``iss=mnemosyne``, ``typ=team``). The returned
|
||||
claims dict is normalized so the middleware doesn't have to guess:
|
||||
|
||||
* ``claims["iss"]`` — as presented (``daedalus`` or ``mnemosyne``).
|
||||
* ``claims["typ"]`` — ``"team"`` for team tokens, otherwise absent.
|
||||
* ``claims["libs"]`` — per-turn only; normalized to ``list[str]``.
|
||||
* ``claims["ws"]`` — per-turn only; may be ``None``. Not consulted
|
||||
for authorization (kept for diagnostics).
|
||||
* ``claims["team_id"]`` — team only; ``UUID`` parsed from
|
||||
``sub == "team:<uuid>"``.
|
||||
* ``claims["kid"]`` — copy of the JWT header's ``kid``.
|
||||
|
||||
Raises :class:`MCPAuthError` on any failure. The per-turn path runs
|
||||
the ``_remember_jti`` replay check; the team path skips it (team
|
||||
JWTs are intentionally reused across the token's lifetime).
|
||||
"""
|
||||
try:
|
||||
unverified_header = pyjwt.get_unverified_header(token_string)
|
||||
@@ -191,7 +223,9 @@ def resolve_mcp_jwt(token_string: str) -> dict:
|
||||
algorithms=["HS256"],
|
||||
leeway=_JWT_LEEWAY_SECONDS,
|
||||
options={"require": ["exp", "iat", "iss", "sub", "jti"]},
|
||||
issuer=_JWT_ISS,
|
||||
# ``issuer=`` accepts ``str | Iterable[str]`` and raises
|
||||
# ``InvalidIssuerError`` if the claim is outside the set.
|
||||
issuer=list(_JWT_ISS_VALUES),
|
||||
)
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
raise MCPAuthError("Token has expired.")
|
||||
@@ -212,19 +246,79 @@ def resolve_mcp_jwt(token_string: str) -> dict:
|
||||
# ``require=["exp", ...]`` above guarantees presence + numeric; this
|
||||
# is defence in depth against future PyJWT changes.
|
||||
raise MCPAuthError("JWT exp must be numeric.")
|
||||
|
||||
typ = claims.get("typ")
|
||||
|
||||
if typ == "team":
|
||||
# Team tokens: no replay cache, no ``libs`` or ``ws`` claims.
|
||||
# Verify the ``sub`` shape and parse the embedded team UUID so
|
||||
# the middleware doesn't have to re-parse it later.
|
||||
sub = claims.get("sub")
|
||||
if not isinstance(sub, str) or not sub.startswith("team:"):
|
||||
raise MCPAuthError("Invalid MCP token.")
|
||||
try:
|
||||
claims["team_id"] = uuid.UUID(sub[len("team:"):])
|
||||
except ValueError:
|
||||
raise MCPAuthError("Invalid MCP token.")
|
||||
else:
|
||||
# Per-turn (legacy) path: replay-cache gate + normalize claims.
|
||||
if _remember_jti(jti, float(exp)):
|
||||
raise MCPAuthError("Token replay detected.")
|
||||
|
||||
# Normalize claim shapes: ws may be null/absent, libs default to [].
|
||||
claims["ws"] = claims.get("ws") or None
|
||||
libs = claims.get("libs") or []
|
||||
if not isinstance(libs, list) or not all(isinstance(x, str) for x in libs):
|
||||
raise MCPAuthError("JWT libs must be a list of strings.")
|
||||
claims["libs"] = libs
|
||||
|
||||
claims["kid"] = kid
|
||||
return claims
|
||||
|
||||
|
||||
# --- Team-JWT library resolution ------------------------------------------
|
||||
|
||||
|
||||
def _libraries_for_team(team_id: uuid.UUID, jti: str) -> list[str]:
|
||||
"""Resolve a team token to the Library UIDs it may read.
|
||||
|
||||
Runs two cheap queries in sequence:
|
||||
|
||||
1. Fetch the ``Team`` row by UUID. Reject if it doesn't exist, is
|
||||
inactive, or its ``active_jti`` doesn't match the incoming
|
||||
``jti`` — this is how rotation / soft-delete revocation becomes
|
||||
effective on the *next* request.
|
||||
2. If active: read every ``TeamWorkspaceAssignment.workspace_id`` for
|
||||
the team and translate them into Library UIDs via a single
|
||||
Cypher query against Neo4j.
|
||||
|
||||
Returns an empty list when the team has no workspace assignments
|
||||
(fail-closed — a team pointing at no workspaces sees no libraries).
|
||||
"""
|
||||
try:
|
||||
team = Team.objects.get(pk=team_id)
|
||||
except Team.DoesNotExist:
|
||||
raise MCPAuthError("Invalid MCP token.")
|
||||
|
||||
if not team.active:
|
||||
raise MCPAuthError("Token has been deactivated.")
|
||||
if team.active_jti is None or str(team.active_jti) != jti:
|
||||
raise MCPAuthError("Invalid MCP token.")
|
||||
|
||||
workspace_ids = list(
|
||||
team.workspace_assignments.values_list("workspace_id", flat=True)
|
||||
)
|
||||
if not workspace_ids:
|
||||
return []
|
||||
|
||||
from neomodel import db
|
||||
|
||||
rows, _ = db.cypher_query(
|
||||
"MATCH (l:Library) WHERE l.workspace_id IN $ws RETURN l.uid",
|
||||
{"ws": workspace_ids},
|
||||
)
|
||||
return [row[0] for row in rows if row and row[0]]
|
||||
|
||||
|
||||
# --- Middleware ------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -234,7 +328,18 @@ class MCPAuthMiddleware(Middleware):
|
||||
|
||||
Listing tools/resources is permitted unauthenticated so clients can
|
||||
discover the surface; calling a tool requires a valid token unless
|
||||
MCP_REQUIRE_AUTH=False.
|
||||
``MCP_REQUIRE_AUTH=False``.
|
||||
|
||||
On every authenticated call the middleware attaches four values to
|
||||
the FastMCP ``Context`` state for downstream tools to consume via
|
||||
:mod:`mcp_server.context`:
|
||||
|
||||
* ``STATE_KEY_USER`` — Django user.
|
||||
* ``STATE_KEY_TOKEN`` — MCPToken row (opaque callers only).
|
||||
* ``STATE_KEY_CLAIMS`` — JWT claims dict (JWT callers only).
|
||||
* ``STATE_KEY_RESOLVED_LIBRARIES`` — authorization-resolved Library
|
||||
UID list. Tools read this; they never read ``STATE_KEY_CLAIMS``
|
||||
for authorization.
|
||||
"""
|
||||
|
||||
# Tools that don't touch user data and must be callable without a token
|
||||
@@ -261,6 +366,7 @@ class MCPAuthMiddleware(Middleware):
|
||||
user = None
|
||||
token = None
|
||||
claims: dict | None = None
|
||||
resolved_libraries: list[str] | None = None
|
||||
|
||||
if token_string:
|
||||
try:
|
||||
@@ -271,10 +377,16 @@ class MCPAuthMiddleware(Middleware):
|
||||
user = await sync_to_async(
|
||||
_resolve_jwt_actor, thread_sensitive=True
|
||||
)(claims)
|
||||
resolved_libraries = await sync_to_async(
|
||||
_resolved_libraries_for_jwt, thread_sensitive=True
|
||||
)(claims)
|
||||
else:
|
||||
user, token = await sync_to_async(
|
||||
resolve_mcp_user, thread_sensitive=True
|
||||
)(token_string)
|
||||
# Opaque tokens store the Library UID list directly.
|
||||
# Empty list = fail-closed; not "everything".
|
||||
resolved_libraries = list(token.allowed_libraries or [])
|
||||
except MCPAuthError as exc:
|
||||
mcp_auth_failures_total.labels(reason=str(exc)).inc()
|
||||
if require_auth:
|
||||
@@ -283,7 +395,6 @@ class MCPAuthMiddleware(Middleware):
|
||||
mcp_auth_failures_total.labels(reason="missing_token").inc()
|
||||
raise PermissionError("Authentication required. Provide a Bearer token.")
|
||||
|
||||
tool_name = self._extract_tool_name(context)
|
||||
if token and tool_name and not token.can_use_tool(tool_name):
|
||||
mcp_auth_failures_total.labels(reason="tool_not_allowed").inc()
|
||||
raise PermissionError(
|
||||
@@ -298,6 +409,18 @@ class MCPAuthMiddleware(Middleware):
|
||||
await fastmcp_ctx.set_state(STATE_KEY_TOKEN, token)
|
||||
if claims is not None:
|
||||
await fastmcp_ctx.set_state(STATE_KEY_CLAIMS, claims)
|
||||
# Always publish resolved_libraries — None means "no auth
|
||||
# information" and the tools treat that as fail-closed.
|
||||
await fastmcp_ctx.set_state(
|
||||
STATE_KEY_RESOLVED_LIBRARIES, resolved_libraries
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"mcp_auth.resolved tool=%s principal=%s lib_count=%s",
|
||||
tool_name,
|
||||
self._describe_principal(user, token, claims),
|
||||
"none" if resolved_libraries is None else len(resolved_libraries),
|
||||
)
|
||||
|
||||
return await self._call_next_with_trace(tool_name, call_next, context)
|
||||
|
||||
@@ -334,6 +457,20 @@ class MCPAuthMiddleware(Middleware):
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _describe_principal(user, token, claims) -> str:
|
||||
"""Compact, log-friendly principal summary. No PII beyond usernames."""
|
||||
if claims is not None:
|
||||
typ = claims.get("typ")
|
||||
if typ == "team":
|
||||
return f"team:{claims.get('team_id')}"
|
||||
return f"jwt:{claims.get('sub')}"
|
||||
if token is not None:
|
||||
return f"mcptoken:{token.get_masked_token()}"
|
||||
if user is not None:
|
||||
return f"user:{user.username}"
|
||||
return "anonymous"
|
||||
|
||||
@staticmethod
|
||||
def _extract_token() -> str | None:
|
||||
"""Pull the Bearer token off the current HTTP request, if any.
|
||||
@@ -412,6 +549,10 @@ def _resolve_jwt_actor(claims: dict):
|
||||
Returns the system service user (``MCP_JWT_SERVICE_USERNAME``, default
|
||||
``daedalus-service``). The user must exist and be active. JWT tokens
|
||||
are not tied to per-user accounts — claims encode all authorization.
|
||||
|
||||
Used for both per-turn and team JWTs. The service user is a hook for
|
||||
usage accounting (LLMUsage / search metrics) and for the audit trail;
|
||||
authorization does not depend on it.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
@@ -426,3 +567,14 @@ def _resolve_jwt_actor(claims: dict):
|
||||
if not user.is_active:
|
||||
raise MCPAuthError(f"JWT service user {username!r} is disabled.")
|
||||
return user
|
||||
|
||||
|
||||
def _resolved_libraries_for_jwt(claims: dict) -> list[str]:
|
||||
"""Pick the right resolver branch for a validated JWT claims dict.
|
||||
|
||||
* ``typ == "team"`` → live lookup via :func:`_libraries_for_team`.
|
||||
* otherwise → legacy ``claims["libs"]`` (per-turn JWT).
|
||||
"""
|
||||
if claims.get("typ") == "team":
|
||||
return _libraries_for_team(claims["team_id"], claims["jti"])
|
||||
return list(claims.get("libs") or [])
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
"""Helpers for accessing the request-scoped MCP user/token from inside tools."""
|
||||
"""Helpers for accessing the request-scoped MCP auth state from inside tools.
|
||||
|
||||
The authoritative values are set by :class:`mcp_server.auth.MCPAuthMiddleware`
|
||||
on the FastMCP ``Context``:
|
||||
|
||||
* ``STATE_KEY_USER`` — the Django user the bearer resolved to (synthetic
|
||||
service user for JWT callers, concrete ``mcp_tokens.user`` for opaque
|
||||
MCPToken callers, ``None`` for team JWTs which are not tied to any
|
||||
per-user account).
|
||||
* ``STATE_KEY_TOKEN`` — the ``MCPToken`` row for opaque-token callers;
|
||||
``None`` for JWT callers.
|
||||
* ``STATE_KEY_CLAIMS`` — the JWT claims dict for JWT callers; ``None``
|
||||
for opaque-token callers. Intentionally exposed for debugging /
|
||||
metrics; tools should NOT branch on claim shape for authorization,
|
||||
they should read :func:`get_mcp_resolved_libraries` instead.
|
||||
* ``STATE_KEY_RESOLVED_LIBRARIES`` — the authorization-resolved Library
|
||||
UID set for this request. ``None`` means the caller is unauthenticated
|
||||
or the auth middleware was bypassed (shouldn't happen in practice);
|
||||
``[]`` means the caller is authenticated but scoped to zero libraries
|
||||
(fail-closed); a non-empty list enumerates the UIDs the caller may read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
from .auth import STATE_KEY_CLAIMS, STATE_KEY_TOKEN, STATE_KEY_USER
|
||||
from .auth import (
|
||||
STATE_KEY_CLAIMS,
|
||||
STATE_KEY_RESOLVED_LIBRARIES,
|
||||
STATE_KEY_TOKEN,
|
||||
STATE_KEY_USER,
|
||||
)
|
||||
|
||||
|
||||
async def get_mcp_user(ctx: Context | None):
|
||||
@@ -24,3 +49,23 @@ async def get_mcp_claims(ctx: Context | None) -> dict | None:
|
||||
if ctx is None:
|
||||
return None
|
||||
return await ctx.get_state(STATE_KEY_CLAIMS)
|
||||
|
||||
|
||||
async def get_mcp_resolved_libraries(ctx: Context | None) -> list[str] | None:
|
||||
"""Return the authorization-resolved Library UID list for this request.
|
||||
|
||||
Semantics (matching ``SearchRequest.resolved_libraries``):
|
||||
|
||||
* ``None`` — no auth information available (e.g. the middleware did
|
||||
not run, or the tool was invoked outside a request context). Tools
|
||||
should treat this as fail-closed and refuse to return content.
|
||||
* ``[]`` — the caller was authenticated but is scoped to zero
|
||||
libraries. Tools MAY proceed and return empty results.
|
||||
* ``["lib_x", …]`` — the caller may read exactly these libraries.
|
||||
|
||||
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3 for the unified
|
||||
auth model that populates this value.
|
||||
"""
|
||||
if ctx is None:
|
||||
return None
|
||||
return await ctx.get_state(STATE_KEY_RESOLVED_LIBRARIES)
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
"""Forms for the MCP token self-service dashboard."""
|
||||
"""Forms for the MCP token self-service dashboard.
|
||||
|
||||
The dashboard is where humans mint their own opaque :class:`MCPToken`
|
||||
rows for external MCP clients (Claude Desktop, Cline, ...). The
|
||||
plaintext is surfaced exactly once on the "created" page and never
|
||||
retrievable again — see ``mcp_server/views.py``.
|
||||
|
||||
Two pickers are rendered:
|
||||
|
||||
* ``allowed_tools`` — multi-select over the FastMCP tool registry.
|
||||
Empty = all tools permitted. Backs ``MCPToken.allowed_tools``.
|
||||
* ``allowed_libraries`` — multi-select over Neo4j Libraries the
|
||||
current request user has ``owner`` or ``manager`` membership on.
|
||||
Empty = **zero** libraries (fail-closed), matching the semantics
|
||||
in §4.1 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,6 +22,7 @@ import functools
|
||||
|
||||
from django import forms
|
||||
|
||||
from .admin import _library_choices_for_user
|
||||
from .models import MCPToken
|
||||
|
||||
|
||||
@@ -51,10 +67,19 @@ class MCPTokenCreateForm(forms.Form):
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
|
||||
help_text="Leave all unchecked to permit every tool.",
|
||||
)
|
||||
allowed_libraries = forms.MultipleChoiceField(
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
|
||||
help_text=(
|
||||
"Libraries this token may read. Empty = zero libraries (fail-closed). "
|
||||
"You only see libraries where you hold owner/manager membership."
|
||||
),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["allowed_tools"].choices = _tool_choices()
|
||||
self.fields["allowed_libraries"].choices = _library_choices_for_user(user)
|
||||
|
||||
|
||||
class MCPTokenEditForm(forms.ModelForm):
|
||||
@@ -65,10 +90,18 @@ class MCPTokenEditForm(forms.ModelForm):
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
|
||||
help_text="Leave all unchecked to permit every tool.",
|
||||
)
|
||||
allowed_libraries = forms.MultipleChoiceField(
|
||||
required=False,
|
||||
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
|
||||
help_text=(
|
||||
"Libraries this token may read. Empty = zero libraries (fail-closed). "
|
||||
"You only see libraries where you hold owner/manager membership."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = MCPToken
|
||||
fields = ["name", "is_active", "expires_at", "allowed_tools"]
|
||||
fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"]
|
||||
widgets = {
|
||||
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
|
||||
@@ -78,8 +111,18 @@ class MCPTokenEditForm(forms.ModelForm):
|
||||
}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["allowed_tools"].choices = _tool_choices()
|
||||
self.fields["allowed_libraries"].choices = _library_choices_for_user(
|
||||
user or (self.instance.user if self.instance and self.instance.pk else None)
|
||||
)
|
||||
if self.instance and self.instance.pk:
|
||||
self.fields["allowed_tools"].initial = self.instance.allowed_tools or []
|
||||
self.fields["allowed_libraries"].initial = (
|
||||
list(self.instance.allowed_libraries or [])
|
||||
)
|
||||
|
||||
def clean_allowed_libraries(self):
|
||||
value = self.cleaned_data.get("allowed_libraries") or []
|
||||
return list(value)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""One-off backfill for ``LibraryMembership`` rows.
|
||||
|
||||
Phase 2 introduces ``LibraryMembership`` as the Postgres-side gate on
|
||||
who may scope a Neo4j-resident Library into a token's
|
||||
``allowed_libraries``. Any Library that existed *before* this migration
|
||||
has no membership rows, so nobody can grant it.
|
||||
|
||||
Running this command assigns **owner** membership on every global
|
||||
Library (``workspace_id IS NULL``) to the first active superuser. It
|
||||
skips:
|
||||
|
||||
* Workspace-scoped Libraries (``workspace_id`` is not null). Those
|
||||
belong to a Daedalus workspace and will be reachable via team JWTs
|
||||
once Phase 4 wires up ``TeamWorkspaceAssignment``. Granting them to
|
||||
a superuser MCPToken would silently widen the blast radius of a
|
||||
leaked token.
|
||||
* Libraries that already have any :class:`LibraryMembership` row. We
|
||||
do not stack roles for idempotency.
|
||||
|
||||
Idempotent. Safe to run on every deploy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from mcp_server.models import LibraryMembership
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Backfill LibraryMembership(role=owner) for every global Neo4j Library, "
|
||||
"assigning it to the first active superuser. Skips workspace-scoped "
|
||||
"Libraries and Libraries that already have any membership row."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--user",
|
||||
dest="username",
|
||||
default=None,
|
||||
help=(
|
||||
"Username to grant ownership to. Defaults to the first active "
|
||||
"superuser, ordered by id ascending."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Report what would be inserted, don't persist.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
User = get_user_model()
|
||||
|
||||
username = options.get("username")
|
||||
if username:
|
||||
try:
|
||||
target = User.objects.get(username=username, is_active=True)
|
||||
except User.DoesNotExist as exc:
|
||||
raise CommandError(
|
||||
f"No active user {username!r} found."
|
||||
) from exc
|
||||
else:
|
||||
target = (
|
||||
User.objects
|
||||
.filter(is_superuser=True, is_active=True)
|
||||
.order_by("id")
|
||||
.first()
|
||||
)
|
||||
if target is None:
|
||||
raise CommandError(
|
||||
"No active superuser to own libraries. "
|
||||
"Pass --user=<username> or create a superuser first."
|
||||
)
|
||||
|
||||
try:
|
||||
from library.models import Library
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise CommandError(
|
||||
f"Could not import library.models.Library (Neo4j unreachable?): {exc}"
|
||||
) from exc
|
||||
|
||||
libraries = list(
|
||||
Library.nodes.filter(workspace_id__isnull=True)
|
||||
)
|
||||
|
||||
already_scoped = set(
|
||||
LibraryMembership.objects
|
||||
.values_list("library_uid", flat=True)
|
||||
)
|
||||
|
||||
to_create = []
|
||||
skipped_workspace = 0
|
||||
skipped_existing = 0
|
||||
for lib in libraries:
|
||||
if not getattr(lib, "uid", None):
|
||||
continue
|
||||
if getattr(lib, "workspace_id", None):
|
||||
# Shouldn't happen — the .filter above should eliminate
|
||||
# them — but belt-and-braces against a misconfigured query.
|
||||
skipped_workspace += 1
|
||||
continue
|
||||
if lib.uid in already_scoped:
|
||||
skipped_existing += 1
|
||||
continue
|
||||
to_create.append(
|
||||
LibraryMembership(
|
||||
user=target,
|
||||
library_uid=lib.uid,
|
||||
role=LibraryMembership.Role.OWNER,
|
||||
)
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.HTTP_INFO(
|
||||
f"Target user: {target.username} (id={target.id})"
|
||||
)
|
||||
)
|
||||
self.stdout.write(
|
||||
f"Global libraries found: {len(libraries)}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" already have a membership: {skipped_existing}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" workspace-scoped (skipped): {skipped_workspace}"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Will insert: {len(to_create)}")
|
||||
)
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(self.style.WARNING("--dry-run: no rows inserted."))
|
||||
for m in to_create:
|
||||
self.stdout.write(f" would insert: {m.library_uid} → {target.username}")
|
||||
return
|
||||
|
||||
if to_create:
|
||||
LibraryMembership.objects.bulk_create(to_create, ignore_conflicts=True)
|
||||
logger.info(
|
||||
"backfill_library_memberships inserted=%d user=%s",
|
||||
len(to_create), target.username,
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS("Done."))
|
||||
@@ -1,4 +1,4 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-26 18:59
|
||||
# Generated by Django 5.2.13 on 2026-05-10 15:31
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
@@ -14,16 +14,46 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='MCPSigningKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('kid', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||
('secret_hex', models.CharField(max_length=128)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('retired_at', models.DateTimeField(blank=True, null=True)),
|
||||
('note', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Team',
|
||||
fields=[
|
||||
('id', models.UUIDField(editable=False, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=200)),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('active_jti', models.UUIDField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='MCPToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('token', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('allowed_tools', models.JSONField(blank=True, default=list)),
|
||||
('allowed_libraries', models.JSONField(blank=True, default=list)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mcp_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
@@ -32,4 +62,32 @@ class Migration(migrations.Migration):
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='LibraryMembership',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('library_uid', models.CharField(db_index=True, max_length=64)),
|
||||
('role', models.CharField(choices=[('owner', 'Owner'), ('manager', 'Manager'), ('reader', 'Reader')], max_length=10)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='library_memberships', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['library_uid', 'role'],
|
||||
'indexes': [models.Index(fields=['library_uid', 'role'], name='mcp_server__library_f19411_idx')],
|
||||
'constraints': [models.UniqueConstraint(fields=('user', 'library_uid'), name='unique_library_membership_per_user')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TeamWorkspaceAssignment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('workspace_id', models.CharField(db_index=True, max_length=64)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_assignments', to='mcp_server.team')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['team', 'workspace_id'],
|
||||
'constraints': [models.UniqueConstraint(fields=('team', 'workspace_id'), name='unique_team_workspace')],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Hash MCPToken values at rest.
|
||||
|
||||
Renames ``token`` → ``token_hash`` and rewrites any pre-existing plaintext
|
||||
values into SHA-256 hex digests in-place. Forward-only: hashing is one-way,
|
||||
so no reverse migration is provided.
|
||||
|
||||
Existing tokens issued before this migration keep working only because
|
||||
``resolve_mcp_user`` hashes the incoming bearer before lookup; the original
|
||||
plaintext the client holds still hashes to what we just wrote.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def hash_existing_tokens(apps, schema_editor):
|
||||
MCPToken = apps.get_model("mcp_server", "MCPToken")
|
||||
for token in MCPToken.objects.all():
|
||||
plaintext = token.token_hash # post-rename, still holds original plaintext
|
||||
token.token_hash = hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||
token.save(update_fields=["token_hash"])
|
||||
|
||||
|
||||
def noop_reverse(apps, schema_editor):
|
||||
# Cannot reverse a hash. Leaving as no-op so the schema can be rolled
|
||||
# back, but operators must understand any hashed rows are unrecoverable.
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("mcp_server", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="mcptoken",
|
||||
old_name="token",
|
||||
new_name="token_hash",
|
||||
),
|
||||
migrations.RunPython(hash_existing_tokens, noop_reverse),
|
||||
]
|
||||
@@ -1,38 +0,0 @@
|
||||
"""HMAC signing keys for per-turn JWTs minted by Daedalus.
|
||||
|
||||
Adds the MCPSigningKey table. Per-turn tokens (workspace + library claims,
|
||||
exp <= 600s) are not stored — only the signing key, indexed by ``kid``,
|
||||
so the signature can be validated and rotated cleanly.
|
||||
"""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("mcp_server", "0002_hash_token"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="MCPSigningKey",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("kid", models.CharField(max_length=64, unique=True, db_index=True)),
|
||||
("secret_hex", models.CharField(max_length=128)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("retired_at", models.DateTimeField(blank=True, null=True)),
|
||||
("note", models.TextField(blank=True)),
|
||||
],
|
||||
options={"ordering": ["-created_at"]},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,31 @@
|
||||
"""Django ORM models for the MCP server app.
|
||||
|
||||
This module defines every Postgres-backed row the Mnemosyne MCP surface
|
||||
relies on:
|
||||
|
||||
* :class:`MCPToken` — opaque bearer tokens (SHA-256 hashed at rest).
|
||||
* :class:`MCPSigningKey` — HMAC signing keys (``HS256``) for JWTs,
|
||||
keyed by ``kid``. Used by the legacy per-turn path *and* by team
|
||||
JWTs minted in §7 of ``DAEDALUS_PALLAS_INTEGRATION_v1.md``.
|
||||
* :class:`LibraryMembership` — Postgres-side role membership for
|
||||
Neo4j-resident Libraries. Referenced by Library ``uid`` string
|
||||
because Library is a neomodel ``StructuredNode``, not a Django
|
||||
ORM model.
|
||||
* :class:`Team` — Pallas deployment identity inside Mnemosyne.
|
||||
Stable UUID = ``PallasInstance.id`` on the Daedalus side.
|
||||
* :class:`TeamWorkspaceAssignment` — which Daedalus workspaces a
|
||||
given team is allowed to see. Queried live on every request so
|
||||
revocation via ``DELETE`` / ``PUT /workspaces/`` is instantaneous.
|
||||
|
||||
See ``mnemosyne/docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` for the
|
||||
complete credential / authorization model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
@@ -11,8 +37,75 @@ def hash_token(plaintext: str) -> str:
|
||||
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Library memberships
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LibraryMembership(models.Model):
|
||||
"""Role of a user on a Neo4j-resident Library.
|
||||
|
||||
Library lives in Neo4j (``library.models.Library``, a neomodel
|
||||
``StructuredNode``), so this table joins by the Library's
|
||||
``uid`` string rather than a foreign key. Consumers that want
|
||||
the Library's live state (name, description, workspace_id, …)
|
||||
must look it up separately via ``Library.nodes.get(uid=…)``.
|
||||
|
||||
Roles are ordered (owner > manager > reader) but not hierarchical
|
||||
in storage: a user with owner rights is represented by a single
|
||||
row with ``role="owner"``, not multiple rows. Callers deciding
|
||||
whether a user may *grant* a Library into an ``MCPToken`` should
|
||||
check for ``role__in=("owner", "manager")``.
|
||||
"""
|
||||
|
||||
class Role(models.TextChoices):
|
||||
OWNER = "owner", "Owner"
|
||||
MANAGER = "manager", "Manager"
|
||||
READER = "reader", "Reader"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="library_memberships",
|
||||
)
|
||||
library_uid = models.CharField(max_length=64, db_index=True)
|
||||
role = models.CharField(max_length=10, choices=Role.choices)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
# One (user, library_uid, role) triple per row. A user may not
|
||||
# hold both ``manager`` and ``reader`` on the same library —
|
||||
# callers must consolidate to the higher role.
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("user", "library_uid"),
|
||||
name="unique_library_membership_per_user",
|
||||
)
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=("library_uid", "role")),
|
||||
]
|
||||
ordering = ["library_uid", "role"]
|
||||
|
||||
def __str__(self): # pragma: no cover - trivial
|
||||
return f"{self.user} → {self.library_uid} ({self.role})"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opaque bearer tokens
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MCPTokenManager(models.Manager):
|
||||
def create_token(self, *, user, name, allowed_tools=None, expires_at=None):
|
||||
def create_token(
|
||||
self,
|
||||
*,
|
||||
user,
|
||||
name,
|
||||
allowed_tools=None,
|
||||
allowed_libraries=None,
|
||||
expires_at=None,
|
||||
):
|
||||
"""Generate a new bearer token, store its hash, and return (instance, plaintext).
|
||||
|
||||
The plaintext is returned exactly once and is never persisted. Callers
|
||||
@@ -25,6 +118,7 @@ class MCPTokenManager(models.Manager):
|
||||
name=name,
|
||||
token_hash=hash_token(plaintext),
|
||||
allowed_tools=list(allowed_tools or []),
|
||||
allowed_libraries=list(allowed_libraries or []),
|
||||
expires_at=expires_at,
|
||||
)
|
||||
return instance, plaintext
|
||||
@@ -36,6 +130,12 @@ class MCPToken(models.Model):
|
||||
Tokens are hashed at rest (SHA-256, 64-char hex). Plaintext exists only in
|
||||
memory at creation time, on the wire to the client, and in the user's own
|
||||
storage. A leaked database backup discloses no usable credentials.
|
||||
|
||||
``allowed_libraries`` is a JSON list of Library ``uid`` strings. It is
|
||||
the sole authorization axis for opaque-token callers: the auth
|
||||
middleware materializes ``resolved_libraries = list(allowed_libraries)``
|
||||
on every request. An empty list is fail-closed (the token sees nothing),
|
||||
not an implicit "all".
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
@@ -49,6 +149,12 @@ class MCPToken(models.Model):
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
allowed_tools = models.JSONField(default=list, blank=True)
|
||||
|
||||
# JSON list of Library.uid strings. Fail-closed: empty → zero libraries.
|
||||
# We cannot use a ``ManyToManyField(Library)`` because Library is a
|
||||
# neomodel ``StructuredNode`` in Neo4j, not a Django ORM model.
|
||||
allowed_libraries = models.JSONField(default=list, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -86,6 +192,11 @@ class MCPToken(models.Model):
|
||||
return f"mcp_…{self.token_hash[:8]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signing keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MCPSigningKeyManager(models.Manager):
|
||||
def active(self):
|
||||
"""Active keys, newest first. Multiple may overlap during rotation."""
|
||||
@@ -94,17 +205,26 @@ class MCPSigningKeyManager(models.Manager):
|
||||
def by_kid(self, kid: str):
|
||||
return self.filter(kid=kid).first()
|
||||
|
||||
def current(self):
|
||||
"""Most recently seeded active key — used when minting new tokens."""
|
||||
return self.filter(is_active=True).order_by("-created_at").first()
|
||||
|
||||
|
||||
class MCPSigningKey(models.Model):
|
||||
"""HMAC signing key for per-turn JWTs minted by Daedalus.
|
||||
"""HMAC signing key used for every Mnemosyne JWT (``HS256``).
|
||||
|
||||
Per-turn tokens carry workspace + library claims and expire in minutes.
|
||||
They are validated entirely off the signature + claims; no row is stored
|
||||
per token. Only the *signing key* is persisted here, indexed by ``kid``.
|
||||
Two populations of tokens share this keyring:
|
||||
|
||||
Rotation: seed a new active key, distribute the secret to Daedalus,
|
||||
flip the old one ``is_active=False``. In-flight tokens with the retired
|
||||
``kid`` fail at ``exp`` (bounded by the per-turn TTL).
|
||||
* **Per-turn JWTs** (legacy, category 2) minted by Daedalus with
|
||||
``exp`` ≤ 10 minutes. Retired in Phase 4.
|
||||
* **Team JWTs** (category 3) minted by Mnemosyne itself with
|
||||
``exp`` = 10 years. Signed with whichever ``MCPSigningKey`` was
|
||||
``objects.current()`` at mint time.
|
||||
|
||||
Rotation: seed a new active key, distribute the secret to
|
||||
Daedalus (for the per-turn path) and re-issue every team token
|
||||
via ``POST /mcp_server/api/teams/{id}/rotate/`` (for the team
|
||||
path), then flip the old one ``is_active=False``.
|
||||
"""
|
||||
|
||||
kid = models.CharField(max_length=64, unique=True, db_index=True)
|
||||
@@ -127,3 +247,87 @@ class MCPSigningKey(models.Model):
|
||||
self.is_active = False
|
||||
self.retired_at = timezone.now()
|
||||
self.save(update_fields=["is_active", "retired_at"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pallas teams
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
"""A Pallas deployment as seen by Mnemosyne.
|
||||
|
||||
``id`` is the Daedalus ``PallasInstance.id`` UUID and is stable
|
||||
across re-deployments / host moves of the same Pallas instance.
|
||||
|
||||
``active_jti`` identifies the single currently-valid team JWT for
|
||||
this team. On ``POST /rotate/`` we generate a new UUID here and
|
||||
re-mint; the previous JWT is invalidated immediately because the
|
||||
auth middleware compares the incoming ``jti`` against this value.
|
||||
|
||||
``active=False`` soft-deletes the team — every validation will
|
||||
reject tokens whose ``sub`` resolves to this row, so revocation
|
||||
survives restart without needing a cache purge.
|
||||
"""
|
||||
|
||||
id = models.UUIDField(primary_key=True, editable=False)
|
||||
name = models.CharField(max_length=200)
|
||||
active = models.BooleanField(default=True)
|
||||
active_jti = models.UUIDField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["name"]
|
||||
|
||||
def __str__(self):
|
||||
suffix = "" if self.active else " [inactive]"
|
||||
return f"{self.name}{suffix}"
|
||||
|
||||
def rotate_jti(self) -> uuid.UUID:
|
||||
"""Install a fresh ``active_jti``. Returns the new UUID."""
|
||||
self.active_jti = uuid.uuid4()
|
||||
self.save(update_fields=["active_jti", "updated_at"])
|
||||
return self.active_jti
|
||||
|
||||
def deactivate(self):
|
||||
"""Soft-delete the team. All its JWTs stop validating on next request."""
|
||||
self.active = False
|
||||
self.active_jti = None
|
||||
self.save(update_fields=["active", "active_jti", "updated_at"])
|
||||
|
||||
|
||||
class TeamWorkspaceAssignment(models.Model):
|
||||
"""Grant a team read access to a Daedalus workspace's libraries.
|
||||
|
||||
Queried live on every request via::
|
||||
|
||||
MATCH (l:Library)
|
||||
WHERE l.workspace_id IN $workspace_ids
|
||||
RETURN l.uid
|
||||
|
||||
so attach/detach is visible to subsequent requests without any
|
||||
cache invalidation. ``workspace_id`` is a plain string (Daedalus
|
||||
owns the namespace) rather than a foreign key, mirroring how
|
||||
``library.models.IngestJob.library_uid`` references Neo4j state.
|
||||
"""
|
||||
|
||||
team = models.ForeignKey(
|
||||
Team,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_assignments",
|
||||
)
|
||||
workspace_id = models.CharField(max_length=64, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=("team", "workspace_id"),
|
||||
name="unique_team_workspace",
|
||||
)
|
||||
]
|
||||
ordering = ["team", "workspace_id"]
|
||||
|
||||
def __str__(self): # pragma: no cover - trivial
|
||||
return f"{self.team} ↔ {self.workspace_id}"
|
||||
|
||||
101
mnemosyne/mcp_server/teams.py
Normal file
101
mnemosyne/mcp_server/teams.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Team JWT minting.
|
||||
|
||||
Team tokens are the ``typ=team`` variant described in §5.2 of
|
||||
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``: long-lived (10 years),
|
||||
no per-request scope claims, revocable via ``Team.active`` and
|
||||
``Team.active_jti``. They are signed with whichever
|
||||
:class:`~mcp_server.models.MCPSigningKey` is currently
|
||||
``MCPSigningKey.objects.current()`` — the same keyring the legacy
|
||||
per-turn path uses.
|
||||
|
||||
Typical flow from the REST API:
|
||||
|
||||
team = Team.objects.get(pk=...)
|
||||
team.rotate_jti() # installs a fresh active_jti
|
||||
jwt_string = mint_team_jwt(team) # signs with current kid + new jti
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import jwt as pyjwt
|
||||
|
||||
from .models import MCPSigningKey, Team
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Team JWT lifetime. 10 years in seconds — matches the "operator cannot
|
||||
# tolerate a silent expiry-induced outage" rationale in §13.1.
|
||||
_TEAM_TOKEN_LIFETIME_SECONDS = 10 * 365 * 24 * 60 * 60
|
||||
|
||||
|
||||
class TeamJWTError(Exception):
|
||||
"""Raised when a team JWT cannot be minted (e.g. no active signing key)."""
|
||||
|
||||
|
||||
def mint_team_jwt(team: Team) -> str:
|
||||
"""Mint a team JWT for ``team`` using the current active signing key.
|
||||
|
||||
Requires:
|
||||
|
||||
* An active :class:`MCPSigningKey` to exist (seed one via
|
||||
``python manage.py seed_signing_key``).
|
||||
* ``team.active_jti`` to be set — callers should invoke
|
||||
:meth:`Team.rotate_jti` immediately before minting so each
|
||||
issuance ties to a fresh UUID, invalidating whatever token
|
||||
preceded it.
|
||||
|
||||
The claim shape mirrors §5.2:
|
||||
|
||||
* ``iss`` = ``"mnemosyne"`` — distinguishes from Daedalus per-turn.
|
||||
* ``aud`` = ``"mnemosyne"`` — informational; not enforced at verify.
|
||||
* ``typ`` = ``"team"`` — the single discriminator the middleware
|
||||
uses to pick the team branch.
|
||||
* ``sub`` = ``"team:<uuid>"`` — carries the team's primary key.
|
||||
* ``jti`` = ``str(active_jti)`` — validated against the DB on every
|
||||
request, so rotate → old token dies.
|
||||
"""
|
||||
key = MCPSigningKey.objects.current()
|
||||
if key is None:
|
||||
raise TeamJWTError(
|
||||
"No active MCPSigningKey to sign the team JWT. "
|
||||
"Seed one via `python manage.py seed_signing_key`."
|
||||
)
|
||||
if team.active_jti is None:
|
||||
raise TeamJWTError(
|
||||
f"Team {team.id} has no active_jti; call rotate_jti() before mint."
|
||||
)
|
||||
|
||||
try:
|
||||
secret = bytes.fromhex(key.secret_hex)
|
||||
except ValueError as exc:
|
||||
raise TeamJWTError(
|
||||
f"Stored secret for kid={key.kid!r} is not valid hex: {exc}"
|
||||
)
|
||||
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"iss": "mnemosyne",
|
||||
"aud": "mnemosyne",
|
||||
"sub": f"team:{team.id}",
|
||||
"typ": "team",
|
||||
"iat": now,
|
||||
"exp": now + _TEAM_TOKEN_LIFETIME_SECONDS,
|
||||
"jti": str(team.active_jti),
|
||||
}
|
||||
token = pyjwt.encode(
|
||||
payload,
|
||||
secret,
|
||||
algorithm="HS256",
|
||||
headers={"kid": key.kid},
|
||||
)
|
||||
logger.info(
|
||||
"team_jwt_minted team_id=%s kid=%s jti=%s",
|
||||
team.id,
|
||||
key.kid,
|
||||
team.active_jti,
|
||||
)
|
||||
return token
|
||||
@@ -1,4 +1,14 @@
|
||||
"""Discovery MCP tools: list libraries, collections, and items."""
|
||||
"""Discovery MCP tools: list libraries, collections, and items.
|
||||
|
||||
Authorization for every call is expressed as ``resolved_libraries`` —
|
||||
the Library UID list the auth middleware attached to the FastMCP
|
||||
``Context``. Tools never consult token claim shapes; they read
|
||||
:func:`mcp_server.context.get_mcp_resolved_libraries` and forward the
|
||||
result as a Cypher parameter.
|
||||
|
||||
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3 for the unified
|
||||
auth model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,27 +17,13 @@ from typing import Any
|
||||
from asgiref.sync import sync_to_async
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
from ..context import get_mcp_claims
|
||||
from ..context import get_mcp_resolved_libraries
|
||||
from ..metrics import record_tool_call
|
||||
|
||||
DEFAULT_LIMIT = 50
|
||||
MAX_LIMIT = 200
|
||||
|
||||
|
||||
def _scope_from_claims(claims: dict | None,
|
||||
arg_workspace_id: str | None) -> tuple[str | None, list[str] | None]:
|
||||
"""Return (workspace_id, allowed_libraries) for a tool call.
|
||||
|
||||
Token claims, when present, trump tool args — that's the security
|
||||
contract. Opaque-token callers (no claims) keep the legacy behavior
|
||||
where the caller may pass workspace_id explicitly (typically null,
|
||||
yielding global scope).
|
||||
"""
|
||||
if claims is not None:
|
||||
return claims.get("ws"), claims.get("libs") or None
|
||||
return arg_workspace_id, None
|
||||
|
||||
|
||||
def _clamp(limit: int) -> int:
|
||||
if limit < 1:
|
||||
return 1
|
||||
@@ -39,8 +35,6 @@ def register_discovery_tools(mcp):
|
||||
async def list_libraries(
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
# System-injected; deliberately absent from the docstring.
|
||||
workspace_id: str | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List Mnemosyne libraries. Each library has a content-aware library_type
|
||||
@@ -49,11 +43,10 @@ def register_discovery_tools(mcp):
|
||||
name, library_type, description for each library — use the uid or
|
||||
library_type to scope a subsequent search.
|
||||
"""
|
||||
claims = await get_mcp_claims(ctx)
|
||||
ws, libs = _scope_from_claims(claims, workspace_id)
|
||||
resolved_libraries = await get_mcp_resolved_libraries(ctx)
|
||||
with record_tool_call("list_libraries"):
|
||||
return await sync_to_async(_query_libraries, thread_sensitive=True)(
|
||||
_clamp(limit), max(offset, 0), ws, libs
|
||||
_clamp(limit), max(offset, 0), resolved_libraries
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
@@ -61,8 +54,6 @@ def register_discovery_tools(mcp):
|
||||
library_uid: str | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
# System-injected; deliberately absent from the docstring.
|
||||
workspace_id: str | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List collections, optionally filtered by parent library_uid.
|
||||
@@ -70,11 +61,10 @@ def register_discovery_tools(mcp):
|
||||
a multi-volume manual). Returns uid, name, description, library_uid,
|
||||
library_name. Use the uid to scope a subsequent search to one collection.
|
||||
"""
|
||||
claims = await get_mcp_claims(ctx)
|
||||
ws, libs = _scope_from_claims(claims, workspace_id)
|
||||
resolved_libraries = await get_mcp_resolved_libraries(ctx)
|
||||
with record_tool_call("list_collections"):
|
||||
return await sync_to_async(_query_collections, thread_sensitive=True)(
|
||||
library_uid, _clamp(limit), max(offset, 0), ws, libs
|
||||
library_uid, _clamp(limit), max(offset, 0), resolved_libraries
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
@@ -83,8 +73,6 @@ def register_discovery_tools(mcp):
|
||||
library_uid: str | None = None,
|
||||
limit: int = DEFAULT_LIMIT,
|
||||
offset: int = 0,
|
||||
# System-injected; deliberately absent from the docstring.
|
||||
workspace_id: str | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List items (the indexed documents/files), optionally filtered by
|
||||
@@ -93,39 +81,42 @@ def register_discovery_tools(mcp):
|
||||
document size; use embedding_status to skip items that are not yet
|
||||
searchable (only 'completed' items appear in search results).
|
||||
"""
|
||||
claims = await get_mcp_claims(ctx)
|
||||
ws, libs = _scope_from_claims(claims, workspace_id)
|
||||
resolved_libraries = await get_mcp_resolved_libraries(ctx)
|
||||
with record_tool_call("list_items"):
|
||||
return await sync_to_async(_query_items, thread_sensitive=True)(
|
||||
collection_uid, library_uid, _clamp(limit), max(offset, 0), ws, libs
|
||||
collection_uid,
|
||||
library_uid,
|
||||
_clamp(limit),
|
||||
max(offset, 0),
|
||||
resolved_libraries,
|
||||
)
|
||||
|
||||
|
||||
_WORKSPACE_SCOPE = (
|
||||
"(($workspace_id IS NOT NULL AND l.workspace_id = $workspace_id) "
|
||||
"OR ($allowed_libraries IS NOT NULL AND l.uid IN $allowed_libraries) "
|
||||
"OR ($workspace_id IS NULL AND $allowed_libraries IS NULL "
|
||||
" AND l.workspace_id IS NULL))"
|
||||
# Single authorization clause shared across every discovery query.
|
||||
# Matches ``library.services.search._RESOLVED_LIBRARIES_CLAUSE`` — the
|
||||
# ``None`` branch lets trusted callers bypass, a non-empty list scopes
|
||||
# to those UIDs, and an empty list is fail-closed (yields nothing).
|
||||
_RESOLVED_LIBRARIES_CLAUSE = (
|
||||
"($resolved_libraries IS NULL OR l.uid IN $resolved_libraries)"
|
||||
)
|
||||
|
||||
|
||||
def _query_libraries(
|
||||
limit: int,
|
||||
offset: int,
|
||||
workspace_id: str | None = None,
|
||||
allowed_libraries: list[str] | None = None,
|
||||
resolved_libraries: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
from neomodel import db
|
||||
|
||||
rows, _ = db.cypher_query(
|
||||
"MATCH (l:Library) "
|
||||
f"WHERE {_WORKSPACE_SCOPE} "
|
||||
f"WHERE {_RESOLVED_LIBRARIES_CLAUSE} "
|
||||
"RETURN l.uid, l.name, l.library_type, l.description "
|
||||
"ORDER BY l.name SKIP $offset LIMIT $limit",
|
||||
{
|
||||
"offset": offset, "limit": limit,
|
||||
"workspace_id": workspace_id,
|
||||
"allowed_libraries": allowed_libraries,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"resolved_libraries": resolved_libraries,
|
||||
},
|
||||
)
|
||||
return {
|
||||
@@ -147,20 +138,19 @@ def _query_collections(
|
||||
library_uid: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
workspace_id: str | None = None,
|
||||
allowed_libraries: list[str] | None = None,
|
||||
resolved_libraries: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
from neomodel import db
|
||||
|
||||
base_params = {
|
||||
"offset": offset, "limit": limit,
|
||||
"workspace_id": workspace_id,
|
||||
"allowed_libraries": allowed_libraries,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"resolved_libraries": resolved_libraries,
|
||||
}
|
||||
if library_uid:
|
||||
cypher = (
|
||||
"MATCH (l:Library {uid: $library_uid})-[:CONTAINS]->(c:Collection) "
|
||||
f"WHERE {_WORKSPACE_SCOPE} "
|
||||
f"WHERE {_RESOLVED_LIBRARIES_CLAUSE} "
|
||||
"RETURN c.uid, c.name, c.description, l.uid, l.name "
|
||||
"ORDER BY c.name SKIP $offset LIMIT $limit"
|
||||
)
|
||||
@@ -168,7 +158,7 @@ def _query_collections(
|
||||
else:
|
||||
cypher = (
|
||||
"MATCH (l:Library)-[:CONTAINS]->(c:Collection) "
|
||||
f"WHERE {_WORKSPACE_SCOPE} "
|
||||
f"WHERE {_RESOLVED_LIBRARIES_CLAUSE} "
|
||||
"RETURN c.uid, c.name, c.description, l.uid, l.name "
|
||||
"ORDER BY l.name, c.name SKIP $offset LIMIT $limit"
|
||||
)
|
||||
@@ -196,16 +186,15 @@ def _query_items(
|
||||
library_uid: str | None,
|
||||
limit: int,
|
||||
offset: int,
|
||||
workspace_id: str | None = None,
|
||||
allowed_libraries: list[str] | None = None,
|
||||
resolved_libraries: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
from neomodel import db
|
||||
|
||||
where = [_WORKSPACE_SCOPE]
|
||||
where = [_RESOLVED_LIBRARIES_CLAUSE]
|
||||
params: dict[str, Any] = {
|
||||
"offset": offset, "limit": limit,
|
||||
"workspace_id": workspace_id,
|
||||
"allowed_libraries": allowed_libraries,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"resolved_libraries": resolved_libraries,
|
||||
}
|
||||
if collection_uid:
|
||||
where.append("c.uid = $collection_uid")
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
"""Search-related MCP tools: hybrid `search` and `get_chunk` for full text."""
|
||||
"""Search-related MCP tools: hybrid ``search`` and ``get_chunk`` for full text.
|
||||
|
||||
Authorization for every call is expressed as ``resolved_libraries`` —
|
||||
the Library UID list the auth middleware attached to the FastMCP
|
||||
``Context``. Tools never consult token claim shapes; they read
|
||||
:func:`mcp_server.context.get_mcp_resolved_libraries` and pass the
|
||||
result straight through to the search layer.
|
||||
|
||||
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3 for the unified
|
||||
auth model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,18 +20,9 @@ from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from fastmcp.server.context import Context
|
||||
|
||||
from ..context import get_mcp_claims, get_mcp_user
|
||||
from ..context import get_mcp_resolved_libraries, get_mcp_user
|
||||
from ..metrics import record_tool_call
|
||||
|
||||
|
||||
def _scope_from_claims(claims: dict | None,
|
||||
arg_workspace_id: str | None) -> tuple[str | None, list[str] | None]:
|
||||
"""Return (workspace_id, allowed_libraries) for a tool call. Token claims
|
||||
trump tool args when present."""
|
||||
if claims is not None:
|
||||
return claims.get("ws"), claims.get("libs") or None
|
||||
return arg_workspace_id, None
|
||||
|
||||
DEFAULT_SEARCH_TYPES = ["vector", "fulltext", "graph"]
|
||||
|
||||
|
||||
@@ -36,11 +37,6 @@ def register_search_tools(mcp):
|
||||
rerank: bool = True,
|
||||
include_images: bool = True,
|
||||
search_types: list[str] | None = None,
|
||||
# workspace_id is system-injected by Daedalus's chat path. It is
|
||||
# intentionally absent from the docstring so the calling LLM is
|
||||
# never told it exists. Whatever value the LLM produces here is
|
||||
# overwritten by Daedalus before the call reaches Mnemosyne.
|
||||
workspace_id: str | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Hybrid retrieval over Mnemosyne: vector + full-text + concept-graph
|
||||
@@ -56,8 +52,7 @@ def register_search_tools(mcp):
|
||||
score, and source. Also returns matching images when include_images=True.
|
||||
"""
|
||||
types = search_types or DEFAULT_SEARCH_TYPES
|
||||
claims = await get_mcp_claims(ctx)
|
||||
ws, libs = _scope_from_claims(claims, workspace_id)
|
||||
resolved_libraries = await get_mcp_resolved_libraries(ctx)
|
||||
with record_tool_call("search"):
|
||||
user = await get_mcp_user(ctx)
|
||||
return await sync_to_async(_run_search, thread_sensitive=True)(
|
||||
@@ -66,8 +61,7 @@ def register_search_tools(mcp):
|
||||
library_uid=library_uid,
|
||||
library_type=library_type,
|
||||
collection_uid=collection_uid,
|
||||
workspace_id=ws,
|
||||
allowed_libraries=libs,
|
||||
resolved_libraries=resolved_libraries,
|
||||
limit=limit,
|
||||
rerank=rerank,
|
||||
include_images=include_images,
|
||||
@@ -77,8 +71,6 @@ def register_search_tools(mcp):
|
||||
@mcp.tool
|
||||
async def get_chunk(
|
||||
chunk_uid: str,
|
||||
# System-injected; deliberately absent from the docstring.
|
||||
workspace_id: str | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch the full text of a chunk by its uid (typically obtained from `search`).
|
||||
@@ -87,17 +79,26 @@ def register_search_tools(mcp):
|
||||
item_uid, item_title, library_type, text. Use this when the 500-character
|
||||
text_preview from `search` isn't enough.
|
||||
"""
|
||||
claims = await get_mcp_claims(ctx)
|
||||
ws, libs = _scope_from_claims(claims, workspace_id)
|
||||
resolved_libraries = await get_mcp_resolved_libraries(ctx)
|
||||
with record_tool_call("get_chunk"):
|
||||
return await sync_to_async(_load_chunk, thread_sensitive=True)(
|
||||
chunk_uid, ws, libs
|
||||
chunk_uid, resolved_libraries
|
||||
)
|
||||
|
||||
|
||||
def _run_search(*, user, query, library_uid, library_type, collection_uid,
|
||||
workspace_id, allowed_libraries, limit, rerank, include_images,
|
||||
search_types) -> dict[str, Any]:
|
||||
def _run_search(
|
||||
*,
|
||||
user,
|
||||
query,
|
||||
library_uid,
|
||||
library_type,
|
||||
collection_uid,
|
||||
resolved_libraries,
|
||||
limit,
|
||||
rerank,
|
||||
include_images,
|
||||
search_types,
|
||||
) -> dict[str, Any]:
|
||||
from library.services.search import SearchRequest, SearchService
|
||||
|
||||
req = SearchRequest(
|
||||
@@ -105,8 +106,7 @@ def _run_search(*, user, query, library_uid, library_type, collection_uid,
|
||||
library_uid=library_uid,
|
||||
library_type=library_type,
|
||||
collection_uid=collection_uid,
|
||||
workspace_id=workspace_id,
|
||||
allowed_libraries=allowed_libraries,
|
||||
resolved_libraries=resolved_libraries,
|
||||
search_types=search_types,
|
||||
limit=limit,
|
||||
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),
|
||||
@@ -130,24 +130,26 @@ def _run_search(*, user, query, library_uid, library_type, collection_uid,
|
||||
|
||||
def _load_chunk(
|
||||
chunk_uid: str,
|
||||
workspace_id: str | None = None,
|
||||
allowed_libraries: list[str] | None = None,
|
||||
resolved_libraries: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load a single chunk's full text, subject to the caller's library scope.
|
||||
|
||||
``resolved_libraries`` is enforced at the Cypher layer: ``None`` allows
|
||||
any library (only used for trusted in-process callers — MCP middleware
|
||||
never passes ``None`` for authenticated clients), ``[]`` matches zero
|
||||
rows (fail-closed), a non-empty list restricts to those UIDs.
|
||||
"""
|
||||
from neomodel import db
|
||||
|
||||
rows, _ = db.cypher_query(
|
||||
"MATCH (l:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->"
|
||||
"(i:Item)-[:HAS_CHUNK]->(c:Chunk {uid: $uid}) "
|
||||
"WHERE (($workspace_id IS NOT NULL AND l.workspace_id = $workspace_id) "
|
||||
" OR ($allowed_libraries IS NOT NULL AND l.uid IN $allowed_libraries) "
|
||||
" OR ($workspace_id IS NULL AND $allowed_libraries IS NULL "
|
||||
" AND l.workspace_id IS NULL)) "
|
||||
"WHERE ($resolved_libraries IS NULL OR l.uid IN $resolved_libraries) "
|
||||
"RETURN c.uid, c.chunk_index, c.chunk_s3_key, "
|
||||
"i.uid, i.title, l.library_type LIMIT 1",
|
||||
{
|
||||
"uid": chunk_uid,
|
||||
"workspace_id": workspace_id,
|
||||
"allowed_libraries": allowed_libraries,
|
||||
"resolved_libraries": resolved_libraries,
|
||||
},
|
||||
)
|
||||
if not rows:
|
||||
|
||||
@@ -1,4 +1,19 @@
|
||||
"""URL routes for the MCP token self-service dashboard."""
|
||||
"""URL routes for the per-user MCP token self-service dashboard.
|
||||
|
||||
Mounted at ``/profile/mcp-tokens/…``. Humans use this surface to mint
|
||||
opaque :class:`mcp_server.models.MCPToken` rows for third-party MCP
|
||||
clients (Claude Desktop, Cline, etc.).
|
||||
|
||||
Other MCP-server surfaces live elsewhere:
|
||||
|
||||
* ``/mcp_server/api/…`` (DRF control plane consumed by Daedalus) is
|
||||
mounted at project root — see ``mnemosyne.urls`` and
|
||||
``mcp_server.api.urls`` — and keeps its own ``mcp-server-api``
|
||||
namespace.
|
||||
* The MCP bearer-auth surface itself (tool calls via
|
||||
``Authorization: Bearer …``) is mounted by ``mnemosyne.asgi`` at
|
||||
``/mcp/`` and is not routed here.
|
||||
"""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
@@ -7,6 +22,7 @@ from . import views
|
||||
app_name = "mcp_server"
|
||||
|
||||
urlpatterns = [
|
||||
# Self-service token dashboard (human-facing).
|
||||
path("profile/mcp-tokens/", views.mcp_token_list, name="mcp-token-list"),
|
||||
path("profile/mcp-tokens/add/", views.mcp_token_create, name="mcp-token-create"),
|
||||
path("profile/mcp-tokens/<int:pk>/", views.mcp_token_detail, name="mcp-token-detail"),
|
||||
|
||||
@@ -28,12 +28,13 @@ def mcp_token_list(request: HttpRequest) -> HttpResponse:
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def mcp_token_create(request: HttpRequest) -> HttpResponse:
|
||||
if request.method == "POST":
|
||||
form = MCPTokenCreateForm(request.POST)
|
||||
form = MCPTokenCreateForm(request.POST, user=request.user)
|
||||
if form.is_valid():
|
||||
token, plaintext = MCPToken.objects.create_token(
|
||||
user=request.user,
|
||||
name=form.cleaned_data["name"],
|
||||
allowed_tools=form.cleaned_data.get("allowed_tools") or [],
|
||||
allowed_libraries=form.cleaned_data.get("allowed_libraries") or [],
|
||||
expires_at=form.cleaned_data.get("expires_at") or None,
|
||||
)
|
||||
return render(
|
||||
@@ -42,7 +43,7 @@ def mcp_token_create(request: HttpRequest) -> HttpResponse:
|
||||
{"token": token, "plaintext": plaintext},
|
||||
)
|
||||
else:
|
||||
form = MCPTokenCreateForm()
|
||||
form = MCPTokenCreateForm(user=request.user)
|
||||
|
||||
return render(request, "mcp_server/tokens/create.html", {"form": form})
|
||||
|
||||
@@ -60,15 +61,18 @@ def mcp_token_edit(request: HttpRequest, pk: int) -> HttpResponse:
|
||||
token = get_object_or_404(MCPToken, pk=pk, user=request.user)
|
||||
|
||||
if request.method == "POST":
|
||||
form = MCPTokenEditForm(request.POST, instance=token)
|
||||
form = MCPTokenEditForm(request.POST, instance=token, user=request.user)
|
||||
if form.is_valid():
|
||||
instance = form.save(commit=False)
|
||||
instance.allowed_tools = form.cleaned_data.get("allowed_tools") or []
|
||||
instance.allowed_libraries = (
|
||||
form.cleaned_data.get("allowed_libraries") or []
|
||||
)
|
||||
instance.save()
|
||||
messages.success(request, "MCP token updated.")
|
||||
return redirect("mcp_server:mcp-token-detail", pk=token.pk)
|
||||
else:
|
||||
form = MCPTokenEditForm(instance=token)
|
||||
form = MCPTokenEditForm(instance=token, user=request.user)
|
||||
|
||||
return render(
|
||||
request, "mcp_server/tokens/edit.html", {"form": form, "token": token}
|
||||
|
||||
@@ -28,6 +28,11 @@ urlpatterns = [
|
||||
path("library/", include("library.urls")),
|
||||
# LLM Manager
|
||||
path("llm/", include("llm_manager.urls")),
|
||||
# MCP server (token dashboard at /profile/mcp-tokens/)
|
||||
# MCP server — two surfaces:
|
||||
# /profile/mcp-tokens/… — per-user self-service token dashboard (HTML, session auth)
|
||||
# /mcp_server/api/… — Daedalus-facing team control plane (DRF, Basic auth)
|
||||
# The MCP bearer-auth surface itself (tool calls) is mounted by
|
||||
# mnemosyne.asgi at /mcp/ and is not routed here.
|
||||
path("", include("mcp_server.urls")),
|
||||
path("mcp_server/api/", include("mcp_server.api.urls")),
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.13 on 2026-04-27 11:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('themis', '0003_alter_userprofile_current_timezone_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userapikey',
|
||||
name='key_type',
|
||||
field=models.CharField(choices=[('api', 'API Key'), ('dav', 'DAV Credentials'), ('token', 'Access Token'), ('secret', 'Secret Key'), ('other', 'Other')], default='api', help_text='Type of credential', max_length=30),
|
||||
),
|
||||
]
|
||||
Reference in New Issue
Block a user