docs: clarify Daedalus-Pallas integration auth model
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 51s
CVE Scan & Docker Build / build-and-push (push) Successful in 2m27s

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:
2026-05-10 11:59:44 -04:00
parent e9f6eeb1a3
commit 16fb7ff4dc
35 changed files with 1839 additions and 2035 deletions

View File

@@ -23,9 +23,12 @@ model connecting three services:
The model replaces the per-turn JWT *forwarding* scheme with a unified The model replaces the per-turn JWT *forwarding* scheme with a unified
**bearer → resolved library set** abstraction. Every authenticated **bearer → resolved library set** abstraction. Every authenticated
Mnemosyne request resolves to a set of Library UIDs the caller may Mnemosyne request resolves to a single ordered `resolved_libraries`
read; the principal type (opaque `MCPToken`, Daedalus per-turn JWT, list of Library UIDs the caller may read; the principal type (opaque
team JWT) only determines how that set is derived. `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 It also records the UX shift in Daedalus: **workspaces attach Teams
(Pallas instances), not individual agents**; the agent picker in chat (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 ### 3.3 Resolved-library abstraction
Mnemosyne's auth middleware populates a single Mnemosyne's auth middleware populates a single
`resolved_libraries: set[str]` per request. Downstream code (search, `resolved_libraries: list[str]` per request. Downstream code (search,
get_document, list_libraries, etc.) only reads that set; it does not get_chunk, list_libraries, list_collections, list_items, …) only
care where the set came from. reads that list; it does not care where it came from.
``` ```
Bearer → classify → dispatch Bearer → classify → dispatch
├─ Opaque MCPToken → allowed_libraries M2M ├─ Opaque MCPToken → token.allowed_libraries (JSON list of UIDs)
├─ per-turn JWT → claims["libs"] ├─ per-turn JWT → claims["libs"]
└─ team JWT (typ=team) → live DB: team.workspaces → libraries └─ team JWT (typ=team) → live DB join:
(filtered by Library.workspace_id) TeamWorkspaceAssignment.workspace_id
→ Library.workspace_id → Library.uid
resolved_libraries: set[str] resolved_libraries: list[str]
downstream tools downstream tools
``` ```
Fail-closed: if the resolution produces an empty set, the request sees Fail-closed: if the resolution produces an empty list, the request
no Libraries. There is no "empty means everything" path. 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 User can scope a Library into `MCPToken.allowed_libraries` iff they
have `owner` or `manager` role on it. have `owner` or `manager` role on it.
#### `MCPToken.allowed_libraries` (new M2M on existing model) #### `MCPToken.allowed_libraries` (new field on existing model)
```python ```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. Fail-closed: empty → token grants access to zero libraries.
Admin form filters the picker by the current user's owned/managed 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") typ = claims.get("typ")
if typ == "team": if typ == "team":
# No replay cache — team tokens are reused on every request. # No replay cache — team tokens are reused on every request.
# Validate sub=="team:<uuid>" shape; stash the uuid on claims.
pass pass
else: else:
if _remember_jti(jti, float(exp)): if _remember_jti(jti, float(exp)):
@@ -262,19 +295,31 @@ def resolve_mcp_jwt(token_string: str) -> dict:
return claims return claims
``` ```
Downstream, the middleware branches: Middleware populates `STATE_KEY_RESOLVED_LIBRARIES` per request:
```python ```python
if claims.get("typ") == "team": # Opaque MCPToken
team = Team.objects.get(id=uuid_from_sub(claims["sub"]), 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=True,
active_jti=claims["jti"]) active_jti=claims["jti"])
resolved_libraries = _libraries_for_team(team) resolved_libraries = _libraries_for_team(team) # see below
else:
resolved_libraries = claims["libs"]
``` ```
`_libraries_for_team(team)` = all `Library` UIDs whose `workspace_id` `_libraries_for_team(team)` runs a single Cypher query against Neo4j:
is in the team's `TeamWorkspaceAssignment` set.
```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` ### 6.1 Third-party MCP client with opaque `MCPToken`
1. Client sends `Authorization: Bearer <plaintext>`. 1. Client sends `Authorization: Bearer <plaintext>`.
2. Middleware hashes → looks up `MCPToken` → validates active/expired. 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. 4. Fails closed if empty.
### 6.2 Daedalus chat per-turn JWT (legacy, retires Phase 4) ### 6.2 Daedalus chat per-turn JWT (legacy, retires Phase 4)
Unchanged from today. `iss=daedalus`, `typ` absent, `libs` carries the `iss=daedalus`, `typ` absent, `libs` carries the full library set
workspace's user-managed libraries, `ws` carries the workspace id. Daedalus pre-computed for that turn (the workspace's auto-Library
Mnemosyne validates against `MCPSigningKey` keyed by `kid`. 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) ### 6.3 Agent team (Kottos / Mentor / Iolaus / post-migration Daedalus-chat)
1. Pallas sends `Authorization: Bearer <team-jwt>` (static, read from 1. Pallas sends `Authorization: Bearer <team-jwt>` (static, read from

View File

@@ -99,6 +99,17 @@ class ImageSerializer(serializers.Serializer):
class SearchRequestSerializer(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) query = serializers.CharField(max_length=2000)
library_uid = serializers.CharField(required=False, allow_blank=True) library_uid = serializers.CharField(required=False, allow_blank=True)
library_type = serializers.ChoiceField( library_type = serializers.ChoiceField(
@@ -106,7 +117,6 @@ class SearchRequestSerializer(serializers.Serializer):
required=False, required=False,
) )
collection_uid = serializers.CharField(required=False, allow_blank=True) collection_uid = serializers.CharField(required=False, allow_blank=True)
workspace_id = serializers.CharField(required=False, allow_blank=True)
search_types = serializers.ListField( search_types = serializers.ListField(
child=serializers.ChoiceField(choices=["vector", "fulltext", "graph"]), child=serializers.ChoiceField(choices=["vector", "fulltext", "graph"]),
required=False, required=False,

View File

@@ -479,17 +479,23 @@ def search(request):
from django.conf import settings as django_settings from django.conf import settings as django_settings
from library.services.search import SearchRequest, SearchService from library.services.search import SearchRequest, SearchService
from library.utils import all_library_uids
serializer = SearchRequestSerializer(data=request.data) serializer = SearchRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data 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( search_request = SearchRequest(
query=data["query"], query=data["query"],
library_uid=data.get("library_uid") or None, library_uid=data.get("library_uid") or None,
library_type=data.get("library_type") or None, library_type=data.get("library_type") or None,
collection_uid=data.get("collection_uid") 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"]), search_types=data.get("search_types", ["vector", "fulltext", "graph"]),
limit=data.get("limit", getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20)), limit=data.get("limit", getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20)),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50), 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 django.conf import settings as django_settings
from library.services.search import SearchRequest, SearchService from library.services.search import SearchRequest, SearchService
from library.utils import all_library_uids
serializer = SearchRequestSerializer(data=request.data) serializer = SearchRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -521,7 +528,7 @@ def search_vector(request):
library_uid=data.get("library_uid") or None, library_uid=data.get("library_uid") or None,
library_type=data.get("library_type") or None, library_type=data.get("library_type") or None,
collection_uid=data.get("collection_uid") 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"], search_types=["vector"],
limit=data.get("limit", 20), limit=data.get("limit", 20),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50), 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 django.conf import settings as django_settings
from library.services.search import SearchRequest, SearchService from library.services.search import SearchRequest, SearchService
from library.utils import all_library_uids
serializer = SearchRequestSerializer(data=request.data) serializer = SearchRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -552,7 +560,7 @@ def search_fulltext(request):
library_uid=data.get("library_uid") or None, library_uid=data.get("library_uid") or None,
library_type=data.get("library_type") or None, library_type=data.get("library_type") or None,
collection_uid=data.get("collection_uid") 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"], search_types=["fulltext"],
limit=data.get("limit", 20), limit=data.get("limit", 20),
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30), fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),

View File

@@ -74,6 +74,11 @@ class Command(BaseCommand):
query=query, query=query,
library_uid=options["library_uid"] or None, library_uid=options["library_uid"] or None,
library_type=options["library_type"] 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, search_types=search_types,
limit=limit, limit=limit,
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50), vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),

View File

@@ -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 from django.db import migrations, models

View File

@@ -26,36 +26,47 @@ from .fusion import ImageSearchResult, SearchCandidate, reciprocal_rank_fusion
logger = logging.getLogger(__name__) 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. # Two Cypher branches, picked by whether ``resolved_libraries`` is the
# Returns ONLY content from libraries whose workspace_id matches. # parameter value:
# 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).
# #
# When ``allowed_libraries`` is non-empty alone (no workspace_id), it # * ``None`` — no clause; trusted in-process admin / CLI
# narrows results to those libraries. # use. Returns every library the query hits.
_WORKSPACE_SCOPE_CLAUSE = ( # * non-empty list — ``WHERE lib.uid IN $resolved_libraries``.
" AND (" # * empty list — fail-closed: no row passes because ``uid IN []``
"($workspace_id IS NOT NULL AND lib.workspace_id = $workspace_id) " # is false for every row (Cypher semantics).
"OR ($allowed_libraries IS NOT NULL AND lib.uid IN $allowed_libraries) " #
"OR ($workspace_id IS NULL AND $allowed_libraries IS NULL " # ``Library.workspace_id`` is NOT consulted here. It remains on the
" AND lib.workspace_id IS NULL)" # 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 @dataclass
class SearchRequest: class SearchRequest:
"""Parameters for a search query. """Parameters for a search query.
Scope is single-mode: a request is either workspace-scoped (workspace_id Authorization scope is expressed by ``resolved_libraries``:
set) or global (workspace_id is None). There is no parameter combination
that returns both workspace and global content in one call. * ``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 query: str
@@ -64,11 +75,9 @@ class SearchRequest:
library_uid: Optional[str] = None library_uid: Optional[str] = None
library_type: Optional[str] = None library_type: Optional[str] = None
collection_uid: Optional[str] = None collection_uid: Optional[str] = None
workspace_id: Optional[str] = None # Authorization-resolved Library UID set. See the module-level
# Phase-2 token claim: user-managed libraries the caller may include # ``_RESOLVED_LIBRARIES_CLAUSE`` docstring for semantics.
# alongside their workspace's auto-library. Cypher uses ``IS NULL`` vs resolved_libraries: Optional[list[str]] = None
# non-empty list to gate the second branch of the scope clause.
allowed_libraries: Optional[list[str]] = None
search_types: list[str] = field( search_types: list[str] = field(
default_factory=lambda: ["vector", "fulltext", "graph"] default_factory=lambda: ["vector", "fulltext", "graph"]
) )
@@ -82,19 +91,17 @@ class SearchRequest:
def __post_init__(self): def __post_init__(self):
# Normalize empty strings to None so "" doesn't slip through as # Normalize empty strings to None so "" doesn't slip through as
# truthy at the Cypher boundary. # truthy at the Cypher boundary.
if self.workspace_id == "":
self.workspace_id = None
if self.library_uid == "": if self.library_uid == "":
self.library_uid = None self.library_uid = None
if self.library_type == "": if self.library_type == "":
self.library_type = None self.library_type = None
if self.collection_uid == "": if self.collection_uid == "":
self.collection_uid = None self.collection_uid = None
# Empty list collapses to None so the Cypher branch reads # resolved_libraries: preserve the distinction between None (no
# "$allowed_libraries IS NOT NULL" rather than "size > 0" — keeps # auth clause — trusted caller) and [] (fail-closed). Only
# the parameter binding straightforward and the predicate sargable. # normalize list contents to strip falsy entries.
if self.allowed_libraries is not None and len(self.allowed_libraries) == 0: if isinstance(self.resolved_libraries, list):
self.allowed_libraries = None self.resolved_libraries = [u for u in self.resolved_libraries if u]
@dataclass @dataclass
@@ -347,7 +354,7 @@ class SearchService:
AND ($library_type IS NULL OR lib.library_type = $library_type) AND ($library_type IS NULL OR lib.library_type = $library_type)
AND ($collection_uid IS NULL OR col.uid = $collection_uid) 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, 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, 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_uid": request.library_uid,
"library_type": request.library_type, "library_type": request.library_type,
"collection_uid": request.collection_uid, "collection_uid": request.collection_uid,
"workspace_id": request.workspace_id, "resolved_libraries": request.resolved_libraries,
"allowed_libraries": request.allowed_libraries,
} }
try: try:
@@ -459,7 +465,7 @@ class SearchService:
AND ($library_type IS NULL OR lib.library_type = $library_type) AND ($library_type IS NULL OR lib.library_type = $library_type)
AND ($collection_uid IS NULL OR col.uid = $collection_uid) 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, 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, 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_uid": request.library_uid,
"library_type": request.library_type, "library_type": request.library_type,
"collection_uid": request.collection_uid, "collection_uid": request.collection_uid,
"workspace_id": request.workspace_id, "resolved_libraries": request.resolved_libraries,
"allowed_libraries": request.allowed_libraries,
} }
try: try:
@@ -520,7 +525,7 @@ class SearchService:
WHERE ($library_uid IS NULL OR lib.uid = $library_uid) WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
AND ($library_type IS NULL OR lib.library_type = $library_type) 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, 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, chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
@@ -537,8 +542,7 @@ class SearchService:
"top_k": top_k, "top_k": top_k,
"library_uid": request.library_uid, "library_uid": request.library_uid,
"library_type": request.library_type, "library_type": request.library_type,
"workspace_id": request.workspace_id, "resolved_libraries": request.resolved_libraries,
"allowed_libraries": request.allowed_libraries,
} }
try: try:
@@ -593,7 +597,7 @@ class SearchService:
WHERE ($library_uid IS NULL OR lib.uid = $library_uid) WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
AND ($library_type IS NULL OR lib.library_type = $library_type) AND ($library_type IS NULL OR lib.library_type = $library_type)
""" """
+ _WORKSPACE_SCOPE_CLAUSE + _RESOLVED_LIBRARIES_CLAUSE
+ """ + """
WITH chunk, item, lib, WITH chunk, item, lib,
max(concept_score) AS score, max(concept_score) AS score,
@@ -613,8 +617,7 @@ class SearchService:
"limit": request.fulltext_top_k, "limit": request.fulltext_top_k,
"library_uid": request.library_uid, "library_uid": request.library_uid,
"library_type": request.library_type, "library_type": request.library_type,
"workspace_id": request.workspace_id, "resolved_libraries": request.resolved_libraries,
"allowed_libraries": request.allowed_libraries,
} }
try: try:
@@ -682,7 +685,7 @@ class SearchService:
WHERE ($library_uid IS NULL OR lib.uid = $library_uid) WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
AND ($library_type IS NULL OR lib.library_type = $library_type) 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, RETURN img.uid AS image_uid, img.image_type AS image_type,
img.description AS description, img.s3_key AS s3_key, img.description AS description, img.s3_key AS s3_key,
@@ -698,8 +701,7 @@ class SearchService:
"query_vector": query_vector, "query_vector": query_vector,
"library_uid": request.library_uid, "library_uid": request.library_uid,
"library_type": request.library_type, "library_type": request.library_type,
"workspace_id": request.workspace_id, "resolved_libraries": request.resolved_libraries,
"allowed_libraries": request.allowed_libraries,
} }
try: try:

View File

@@ -21,3 +21,32 @@ def neo4j_available():
return True return True
except Exception: except Exception:
return False 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 []

View File

@@ -141,40 +141,16 @@ _MAX_QUERY_IMAGE_BYTES = 8 * 1024 * 1024
def _all_library_uids() -> list[str]: 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 Kept here so existing tests that patch
``library_search``) are admin/debug tools gated by ``@login_required`` ``library.views._all_library_uids`` continue to work during the
against a local Django account; they are not exposed to external Phase-2 refactor. New code should import ``all_library_uids``
MCP callers and have no workspace-scoping contract to honour. directly from ``library.utils``.
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.
""" """
if not neo4j_available(): from .utils import all_library_uids
return []
try:
from .models import Library
return [lib.uid for lib in Library.nodes.all() if lib.uid] return all_library_uids()
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
logger.warning("Failed to enumerate library UIDs for search: %s", exc)
return []
@login_required @login_required
@@ -240,7 +216,7 @@ def library_search(request, uid):
query_image=image_bytes, query_image=image_bytes,
query_image_ext=image_ext, query_image_ext=image_ext,
library_uid=uid, library_uid=uid,
allowed_libraries=allowed, resolved_libraries=allowed,
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20), limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50), vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30), fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
@@ -830,11 +806,13 @@ def search_page(request):
query=query, query=query,
library_uid=library_uid or None, library_uid=library_uid or None,
library_type=library_type or None, library_type=library_type or None,
# Admin UI sees everything — workspace-scoped libraries # Admin UI is session-authenticated and sees every
# included. Without this, ``_WORKSPACE_SCOPE_CLAUSE`` # library, Daedalus-workspace-scoped or global.
# falls back to its "global-only" branch and silently # ``library.utils.all_library_uids`` materializes the
# hides all Daedalus-ingested content. # full Library UID set as the request's
allowed_libraries=_all_library_uids(), # ``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), limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50), vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30), fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),

View File

@@ -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 django.db.models.deletion
import llm_manager.encryption import llm_manager.encryption
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
fields=[ fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)), ('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()), ('base_url', models.URLField()),
('api_key', llm_manager.encryption.EncryptedCharField(blank=True, default='', max_length=500)), ('api_key', llm_manager.encryption.EncryptedCharField(blank=True, default='', max_length=500)),
('is_active', models.BooleanField(default=True)), ('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_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_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_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)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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')), ('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)), ('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)), ('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)), ('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)), ('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')), ('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)), ('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', model_name='llmmodel',
index=models.Index(fields=['is_system_reranker_model', 'model_type'], name='llm_manager_is_syst_cc73c6_idx'), 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( migrations.AlterUniqueTogether(
name='llmmodel', name='llmmodel',
unique_together={('api', 'name')}, unique_together={('api', 'name')},

View File

@@ -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,
),
),
]

View File

@@ -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,
),
),
]

View File

@@ -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",
),
]

View File

@@ -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) @admin.register(MCPToken)
class MCPTokenAdmin(admin.ModelAdmin): class MCPTokenAdmin(admin.ModelAdmin):
form = MCPTokenAdminForm
list_display = [ list_display = [
"name", "name",
"user", "user",
"is_active", "is_active",
"masked_token", "masked_token",
"library_count",
"expires_at", "expires_at",
"last_used_at", "last_used_at",
"created_at", "created_at",
@@ -19,7 +178,17 @@ class MCPTokenAdmin(admin.ModelAdmin):
readonly_fields = ["token_hash", "last_used_at", "created_at", "updated_at"] readonly_fields = ["token_hash", "last_used_at", "created_at", "updated_at"]
fieldsets = ( fieldsets = (
(None, {"fields": ("user", "name", "is_active")}), (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)", "Token (hashed at rest — plaintext is shown only once at creation)",
{"fields": ("token_hash",)}, {"fields": ("token_hash",)},
@@ -31,6 +200,10 @@ class MCPTokenAdmin(admin.ModelAdmin):
def masked_token(self, obj): def masked_token(self, obj):
return obj.get_masked_token() 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): def has_add_permission(self, request):
# Tokens must be created via the dashboard or management command # Tokens must be created via the dashboard or management command
# so the plaintext can be surfaced to the user. Adding via admin # so the plaintext can be surfaced to the user. Adding via admin
@@ -38,6 +211,11 @@ class MCPTokenAdmin(admin.ModelAdmin):
return False return False
# ---------------------------------------------------------------------------
# MCPSigningKey
# ---------------------------------------------------------------------------
@admin.register(MCPSigningKey) @admin.register(MCPSigningKey)
class MCPSigningKeyAdmin(admin.ModelAdmin): class MCPSigningKeyAdmin(admin.ModelAdmin):
list_display = ["kid", "is_active", "created_at", "retired_at", "note"] list_display = ["kid", "is_active", "created_at", "retired_at", "note"]
@@ -45,3 +223,107 @@ class MCPSigningKeyAdmin(admin.ModelAdmin):
search_fields = ["kid", "note"] search_fields = ["kid", "note"]
readonly_fields = ["created_at", "retired_at"] readonly_fields = ["created_at", "retired_at"]
fields = ["kid", "secret_hex", "is_active", "note", "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"]

View File

View 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

View 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)

View 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",
),
]

View File

@@ -1,27 +1,35 @@
"""MCP token resolution and FastMCP middleware for bearer-token auth. """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, 1. **Opaque ``MCPToken``** (long-lived, hashed at rest). Authorization
used by the dashboard / Claude Desktop / admin tooling. Plaintext scope is its ``allowed_libraries`` JSON list.
hashes to a row in `mcp_token`. 2. **Per-turn signed JWT** (``iss=daedalus``, ≤10 min, legacy — retires
* **Signed JWT** — per-turn token minted by Daedalus. Carries in Phase 4 when Daedalus chat itself becomes a Pallas Team). Scope
`{ws, libs}` claims. Validated entirely off the signature + claims; is the ``libs`` claim.
no database lookup of the token itself, only of the signing key 3. **Team JWT** (``iss=mnemosyne``, ``typ=team``, 10-year lifetime).
(`MCPSigningKey`) referenced by the JWT header's `kid`. 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 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. else falls through to the opaque path.
""" """
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import json import json
import logging import logging
import time import time
import uuid
from collections import OrderedDict from collections import OrderedDict
import jwt as pyjwt import jwt as pyjwt
@@ -32,20 +40,26 @@ from fastmcp.server.dependencies import get_http_request
from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.middleware import Middleware, MiddlewareContext
from .metrics import mcp_auth_failures_total 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__) logger = logging.getLogger(__name__)
STATE_KEY_USER = "mcp_user" STATE_KEY_USER = "mcp_user"
STATE_KEY_TOKEN = "mcp_token" STATE_KEY_TOKEN = "mcp_token"
STATE_KEY_CLAIMS = "mcp_claims" STATE_KEY_CLAIMS = "mcp_claims"
STATE_KEY_RESOLVED_LIBRARIES = "mcp_resolved_libraries"
# Permitted clock skew when validating JWT exp/iat. PyJWT applies this # Permitted clock skew when validating JWT exp/iat. PyJWT applies this
# symmetrically as ``leeway``. # symmetrically as ``leeway``.
_JWT_LEEWAY_SECONDS = 30 _JWT_LEEWAY_SECONDS = 30
# Mnemosyne is the audience; Daedalus is the only accepted issuer. # Accepted JWT issuers.
_JWT_ISS = "daedalus" #
# ``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 # Bounded LRU of recently-seen jti values to discourage replay within
# a single Mnemosyne process. Real defense is short ``exp`` + HMAC; this # 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 # ``exp`` has passed — that's the scenario PyJWT's own ``exp`` check
# would have already rejected, this is belt-and-braces for clock skew # would have already rejected, this is belt-and-braces for clock skew
# or a resurrected captured token. # 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_MAX = 4096
_JTI_CACHE: "OrderedDict[str, float]" = OrderedDict() _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: def resolve_mcp_jwt(token_string: str) -> dict:
"""Validate a signed JWT and return its claims dict. """Validate a signed JWT and return its claims dict.
Raises ``MCPAuthError`` on any failure. Does not touch ``MCPToken`` — Accepts both the legacy per-turn issuer (``iss=daedalus``) and the
JWTs are stateless and stored only as their signing key (``MCPSigningKey``). 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: try:
unverified_header = pyjwt.get_unverified_header(token_string) unverified_header = pyjwt.get_unverified_header(token_string)
@@ -191,7 +223,9 @@ def resolve_mcp_jwt(token_string: str) -> dict:
algorithms=["HS256"], algorithms=["HS256"],
leeway=_JWT_LEEWAY_SECONDS, leeway=_JWT_LEEWAY_SECONDS,
options={"require": ["exp", "iat", "iss", "sub", "jti"]}, 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: except pyjwt.ExpiredSignatureError:
raise MCPAuthError("Token has expired.") raise MCPAuthError("Token has expired.")
@@ -212,19 +246,79 @@ def resolve_mcp_jwt(token_string: str) -> dict:
# ``require=["exp", ...]`` above guarantees presence + numeric; this # ``require=["exp", ...]`` above guarantees presence + numeric; this
# is defence in depth against future PyJWT changes. # is defence in depth against future PyJWT changes.
raise MCPAuthError("JWT exp must be numeric.") 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)): if _remember_jti(jti, float(exp)):
raise MCPAuthError("Token replay detected.") raise MCPAuthError("Token replay detected.")
# Normalize claim shapes: ws may be null/absent, libs default to [].
claims["ws"] = claims.get("ws") or None claims["ws"] = claims.get("ws") or None
libs = claims.get("libs") or [] libs = claims.get("libs") or []
if not isinstance(libs, list) or not all(isinstance(x, str) for x in libs): 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.") raise MCPAuthError("JWT libs must be a list of strings.")
claims["libs"] = libs claims["libs"] = libs
claims["kid"] = kid claims["kid"] = kid
return claims 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 ------------------------------------------------------------ # --- Middleware ------------------------------------------------------------
@@ -234,7 +328,18 @@ class MCPAuthMiddleware(Middleware):
Listing tools/resources is permitted unauthenticated so clients can Listing tools/resources is permitted unauthenticated so clients can
discover the surface; calling a tool requires a valid token unless 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 # Tools that don't touch user data and must be callable without a token
@@ -261,6 +366,7 @@ class MCPAuthMiddleware(Middleware):
user = None user = None
token = None token = None
claims: dict | None = None claims: dict | None = None
resolved_libraries: list[str] | None = None
if token_string: if token_string:
try: try:
@@ -271,10 +377,16 @@ class MCPAuthMiddleware(Middleware):
user = await sync_to_async( user = await sync_to_async(
_resolve_jwt_actor, thread_sensitive=True _resolve_jwt_actor, thread_sensitive=True
)(claims) )(claims)
resolved_libraries = await sync_to_async(
_resolved_libraries_for_jwt, thread_sensitive=True
)(claims)
else: else:
user, token = await sync_to_async( user, token = await sync_to_async(
resolve_mcp_user, thread_sensitive=True resolve_mcp_user, thread_sensitive=True
)(token_string) )(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: except MCPAuthError as exc:
mcp_auth_failures_total.labels(reason=str(exc)).inc() mcp_auth_failures_total.labels(reason=str(exc)).inc()
if require_auth: if require_auth:
@@ -283,7 +395,6 @@ class MCPAuthMiddleware(Middleware):
mcp_auth_failures_total.labels(reason="missing_token").inc() mcp_auth_failures_total.labels(reason="missing_token").inc()
raise PermissionError("Authentication required. Provide a Bearer token.") 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): if token and tool_name and not token.can_use_tool(tool_name):
mcp_auth_failures_total.labels(reason="tool_not_allowed").inc() mcp_auth_failures_total.labels(reason="tool_not_allowed").inc()
raise PermissionError( raise PermissionError(
@@ -298,6 +409,18 @@ class MCPAuthMiddleware(Middleware):
await fastmcp_ctx.set_state(STATE_KEY_TOKEN, token) await fastmcp_ctx.set_state(STATE_KEY_TOKEN, token)
if claims is not None: if claims is not None:
await fastmcp_ctx.set_state(STATE_KEY_CLAIMS, claims) 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) return await self._call_next_with_trace(tool_name, call_next, context)
@@ -334,6 +457,20 @@ class MCPAuthMiddleware(Middleware):
) )
return result 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 @staticmethod
def _extract_token() -> str | None: def _extract_token() -> str | None:
"""Pull the Bearer token off the current HTTP request, if any. """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 Returns the system service user (``MCP_JWT_SERVICE_USERNAME``, default
``daedalus-service``). The user must exist and be active. JWT tokens ``daedalus-service``). The user must exist and be active. JWT tokens
are not tied to per-user accounts — claims encode all authorization. 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 from django.contrib.auth import get_user_model
@@ -426,3 +567,14 @@ def _resolve_jwt_actor(claims: dict):
if not user.is_active: if not user.is_active:
raise MCPAuthError(f"JWT service user {username!r} is disabled.") raise MCPAuthError(f"JWT service user {username!r} is disabled.")
return user 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 [])

View File

@@ -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 __future__ import annotations
from fastmcp.server.context import Context 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): 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: if ctx is None:
return None return None
return await ctx.get_state(STATE_KEY_CLAIMS) 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)

View File

@@ -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 from __future__ import annotations
@@ -7,6 +22,7 @@ import functools
from django import forms from django import forms
from .admin import _library_choices_for_user
from .models import MCPToken from .models import MCPToken
@@ -51,10 +67,19 @@ class MCPTokenCreateForm(forms.Form):
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}), widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
help_text="Leave all unchecked to permit every tool.", 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) super().__init__(*args, **kwargs)
self.fields["allowed_tools"].choices = _tool_choices() self.fields["allowed_tools"].choices = _tool_choices()
self.fields["allowed_libraries"].choices = _library_choices_for_user(user)
class MCPTokenEditForm(forms.ModelForm): class MCPTokenEditForm(forms.ModelForm):
@@ -65,10 +90,18 @@ class MCPTokenEditForm(forms.ModelForm):
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}), widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
help_text="Leave all unchecked to permit every tool.", 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: class Meta:
model = MCPToken model = MCPToken
fields = ["name", "is_active", "expires_at", "allowed_tools"] fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"]
widgets = { widgets = {
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}), "name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
"is_active": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}), "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) super().__init__(*args, **kwargs)
self.fields["allowed_tools"].choices = _tool_choices() 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: if self.instance and self.instance.pk:
self.fields["allowed_tools"].initial = self.instance.allowed_tools or [] 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)

View File

@@ -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."))

View File

@@ -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 import django.db.models.deletion
from django.conf import settings from django.conf import settings
@@ -14,16 +14,46 @@ class Migration(migrations.Migration):
] ]
operations = [ 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( migrations.CreateModel(
name='MCPToken', name='MCPToken',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('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)), ('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)), ('is_active', models.BooleanField(default=True)),
('expires_at', models.DateTimeField(blank=True, null=True)), ('expires_at', models.DateTimeField(blank=True, null=True)),
('last_used_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_tools', models.JSONField(blank=True, default=list)),
('allowed_libraries', models.JSONField(blank=True, default=list)),
('created_at', models.DateTimeField(auto_now_add=True)), ('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=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)), ('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'], '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')],
},
),
] ]

View File

@@ -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),
]

View File

@@ -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"]},
),
]

View File

@@ -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 hashlib
import secrets import secrets
import uuid
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
@@ -11,8 +37,75 @@ def hash_token(plaintext: str) -> str:
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest() 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): 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). """Generate a new bearer token, store its hash, and return (instance, plaintext).
The plaintext is returned exactly once and is never persisted. Callers The plaintext is returned exactly once and is never persisted. Callers
@@ -25,6 +118,7 @@ class MCPTokenManager(models.Manager):
name=name, name=name,
token_hash=hash_token(plaintext), token_hash=hash_token(plaintext),
allowed_tools=list(allowed_tools or []), allowed_tools=list(allowed_tools or []),
allowed_libraries=list(allowed_libraries or []),
expires_at=expires_at, expires_at=expires_at,
) )
return instance, plaintext 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 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 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. 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( user = models.ForeignKey(
@@ -49,6 +149,12 @@ class MCPToken(models.Model):
expires_at = models.DateTimeField(null=True, blank=True) expires_at = models.DateTimeField(null=True, blank=True)
last_used_at = models.DateTimeField(null=True, blank=True) last_used_at = models.DateTimeField(null=True, blank=True)
allowed_tools = models.JSONField(default=list, 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -86,6 +192,11 @@ class MCPToken(models.Model):
return f"mcp_…{self.token_hash[:8]}" return f"mcp_…{self.token_hash[:8]}"
# ---------------------------------------------------------------------------
# Signing keys
# ---------------------------------------------------------------------------
class MCPSigningKeyManager(models.Manager): class MCPSigningKeyManager(models.Manager):
def active(self): def active(self):
"""Active keys, newest first. Multiple may overlap during rotation.""" """Active keys, newest first. Multiple may overlap during rotation."""
@@ -94,17 +205,26 @@ class MCPSigningKeyManager(models.Manager):
def by_kid(self, kid: str): def by_kid(self, kid: str):
return self.filter(kid=kid).first() 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): 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. Two populations of tokens share this keyring:
They are validated entirely off the signature + claims; no row is stored
per token. Only the *signing key* is persisted here, indexed by ``kid``.
Rotation: seed a new active key, distribute the secret to Daedalus, * **Per-turn JWTs** (legacy, category 2) minted by Daedalus with
flip the old one ``is_active=False``. In-flight tokens with the retired ``exp`` ≤ 10 minutes. Retired in Phase 4.
``kid`` fail at ``exp`` (bounded by the per-turn TTL). * **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) kid = models.CharField(max_length=64, unique=True, db_index=True)
@@ -127,3 +247,87 @@ class MCPSigningKey(models.Model):
self.is_active = False self.is_active = False
self.retired_at = timezone.now() self.retired_at = timezone.now()
self.save(update_fields=["is_active", "retired_at"]) 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}"

View 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

View File

@@ -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 from __future__ import annotations
@@ -7,27 +17,13 @@ from typing import Any
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from fastmcp.server.context import Context 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 from ..metrics import record_tool_call
DEFAULT_LIMIT = 50 DEFAULT_LIMIT = 50
MAX_LIMIT = 200 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: def _clamp(limit: int) -> int:
if limit < 1: if limit < 1:
return 1 return 1
@@ -39,8 +35,6 @@ def register_discovery_tools(mcp):
async def list_libraries( async def list_libraries(
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, offset: int = 0,
# System-injected; deliberately absent from the docstring.
workspace_id: str | None = None,
ctx: Context | None = None, ctx: Context | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List Mnemosyne libraries. Each library has a content-aware library_type """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 name, library_type, description for each library — use the uid or
library_type to scope a subsequent search. library_type to scope a subsequent search.
""" """
claims = await get_mcp_claims(ctx) resolved_libraries = await get_mcp_resolved_libraries(ctx)
ws, libs = _scope_from_claims(claims, workspace_id)
with record_tool_call("list_libraries"): with record_tool_call("list_libraries"):
return await sync_to_async(_query_libraries, thread_sensitive=True)( 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 @mcp.tool
@@ -61,8 +54,6 @@ def register_discovery_tools(mcp):
library_uid: str | None = None, library_uid: str | None = None,
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, offset: int = 0,
# System-injected; deliberately absent from the docstring.
workspace_id: str | None = None,
ctx: Context | None = None, ctx: Context | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List collections, optionally filtered by parent library_uid. """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, a multi-volume manual). Returns uid, name, description, library_uid,
library_name. Use the uid to scope a subsequent search to one collection. library_name. Use the uid to scope a subsequent search to one collection.
""" """
claims = await get_mcp_claims(ctx) resolved_libraries = await get_mcp_resolved_libraries(ctx)
ws, libs = _scope_from_claims(claims, workspace_id)
with record_tool_call("list_collections"): with record_tool_call("list_collections"):
return await sync_to_async(_query_collections, thread_sensitive=True)( 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 @mcp.tool
@@ -83,8 +73,6 @@ def register_discovery_tools(mcp):
library_uid: str | None = None, library_uid: str | None = None,
limit: int = DEFAULT_LIMIT, limit: int = DEFAULT_LIMIT,
offset: int = 0, offset: int = 0,
# System-injected; deliberately absent from the docstring.
workspace_id: str | None = None,
ctx: Context | None = None, ctx: Context | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List items (the indexed documents/files), optionally filtered by """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 document size; use embedding_status to skip items that are not yet
searchable (only 'completed' items appear in search results). searchable (only 'completed' items appear in search results).
""" """
claims = await get_mcp_claims(ctx) resolved_libraries = await get_mcp_resolved_libraries(ctx)
ws, libs = _scope_from_claims(claims, workspace_id)
with record_tool_call("list_items"): with record_tool_call("list_items"):
return await sync_to_async(_query_items, thread_sensitive=True)( 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 = ( # Single authorization clause shared across every discovery query.
"(($workspace_id IS NOT NULL AND l.workspace_id = $workspace_id) " # Matches ``library.services.search._RESOLVED_LIBRARIES_CLAUSE`` — the
"OR ($allowed_libraries IS NOT NULL AND l.uid IN $allowed_libraries) " # ``None`` branch lets trusted callers bypass, a non-empty list scopes
"OR ($workspace_id IS NULL AND $allowed_libraries IS NULL " # to those UIDs, and an empty list is fail-closed (yields nothing).
" AND l.workspace_id IS NULL))" _RESOLVED_LIBRARIES_CLAUSE = (
"($resolved_libraries IS NULL OR l.uid IN $resolved_libraries)"
) )
def _query_libraries( def _query_libraries(
limit: int, limit: int,
offset: int, offset: int,
workspace_id: str | None = None, resolved_libraries: list[str] | None,
allowed_libraries: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from neomodel import db from neomodel import db
rows, _ = db.cypher_query( rows, _ = db.cypher_query(
"MATCH (l:Library) " "MATCH (l:Library) "
f"WHERE {_WORKSPACE_SCOPE} " f"WHERE {_RESOLVED_LIBRARIES_CLAUSE} "
"RETURN l.uid, l.name, l.library_type, l.description " "RETURN l.uid, l.name, l.library_type, l.description "
"ORDER BY l.name SKIP $offset LIMIT $limit", "ORDER BY l.name SKIP $offset LIMIT $limit",
{ {
"offset": offset, "limit": limit, "offset": offset,
"workspace_id": workspace_id, "limit": limit,
"allowed_libraries": allowed_libraries, "resolved_libraries": resolved_libraries,
}, },
) )
return { return {
@@ -147,20 +138,19 @@ def _query_collections(
library_uid: str | None, library_uid: str | None,
limit: int, limit: int,
offset: int, offset: int,
workspace_id: str | None = None, resolved_libraries: list[str] | None,
allowed_libraries: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from neomodel import db from neomodel import db
base_params = { base_params = {
"offset": offset, "limit": limit, "offset": offset,
"workspace_id": workspace_id, "limit": limit,
"allowed_libraries": allowed_libraries, "resolved_libraries": resolved_libraries,
} }
if library_uid: if library_uid:
cypher = ( cypher = (
"MATCH (l:Library {uid: $library_uid})-[:CONTAINS]->(c:Collection) " "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 " "RETURN c.uid, c.name, c.description, l.uid, l.name "
"ORDER BY c.name SKIP $offset LIMIT $limit" "ORDER BY c.name SKIP $offset LIMIT $limit"
) )
@@ -168,7 +158,7 @@ def _query_collections(
else: else:
cypher = ( cypher = (
"MATCH (l:Library)-[:CONTAINS]->(c:Collection) " "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 " "RETURN c.uid, c.name, c.description, l.uid, l.name "
"ORDER BY l.name, c.name SKIP $offset LIMIT $limit" "ORDER BY l.name, c.name SKIP $offset LIMIT $limit"
) )
@@ -196,16 +186,15 @@ def _query_items(
library_uid: str | None, library_uid: str | None,
limit: int, limit: int,
offset: int, offset: int,
workspace_id: str | None = None, resolved_libraries: list[str] | None,
allowed_libraries: list[str] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
from neomodel import db from neomodel import db
where = [_WORKSPACE_SCOPE] where = [_RESOLVED_LIBRARIES_CLAUSE]
params: dict[str, Any] = { params: dict[str, Any] = {
"offset": offset, "limit": limit, "offset": offset,
"workspace_id": workspace_id, "limit": limit,
"allowed_libraries": allowed_libraries, "resolved_libraries": resolved_libraries,
} }
if collection_uid: if collection_uid:
where.append("c.uid = $collection_uid") where.append("c.uid = $collection_uid")

View File

@@ -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 from __future__ import annotations
@@ -10,18 +20,9 @@ from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from fastmcp.server.context import Context 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 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"] DEFAULT_SEARCH_TYPES = ["vector", "fulltext", "graph"]
@@ -36,11 +37,6 @@ def register_search_tools(mcp):
rerank: bool = True, rerank: bool = True,
include_images: bool = True, include_images: bool = True,
search_types: list[str] | None = None, 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, ctx: Context | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Hybrid retrieval over Mnemosyne: vector + full-text + concept-graph """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. score, and source. Also returns matching images when include_images=True.
""" """
types = search_types or DEFAULT_SEARCH_TYPES types = search_types or DEFAULT_SEARCH_TYPES
claims = await get_mcp_claims(ctx) resolved_libraries = await get_mcp_resolved_libraries(ctx)
ws, libs = _scope_from_claims(claims, workspace_id)
with record_tool_call("search"): with record_tool_call("search"):
user = await get_mcp_user(ctx) user = await get_mcp_user(ctx)
return await sync_to_async(_run_search, thread_sensitive=True)( return await sync_to_async(_run_search, thread_sensitive=True)(
@@ -66,8 +61,7 @@ def register_search_tools(mcp):
library_uid=library_uid, library_uid=library_uid,
library_type=library_type, library_type=library_type,
collection_uid=collection_uid, collection_uid=collection_uid,
workspace_id=ws, resolved_libraries=resolved_libraries,
allowed_libraries=libs,
limit=limit, limit=limit,
rerank=rerank, rerank=rerank,
include_images=include_images, include_images=include_images,
@@ -77,8 +71,6 @@ def register_search_tools(mcp):
@mcp.tool @mcp.tool
async def get_chunk( async def get_chunk(
chunk_uid: str, chunk_uid: str,
# System-injected; deliberately absent from the docstring.
workspace_id: str | None = None,
ctx: Context | None = None, ctx: Context | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Fetch the full text of a chunk by its uid (typically obtained from `search`). """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 item_uid, item_title, library_type, text. Use this when the 500-character
text_preview from `search` isn't enough. text_preview from `search` isn't enough.
""" """
claims = await get_mcp_claims(ctx) resolved_libraries = await get_mcp_resolved_libraries(ctx)
ws, libs = _scope_from_claims(claims, workspace_id)
with record_tool_call("get_chunk"): with record_tool_call("get_chunk"):
return await sync_to_async(_load_chunk, thread_sensitive=True)( 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, def _run_search(
workspace_id, allowed_libraries, limit, rerank, include_images, *,
search_types) -> dict[str, Any]: 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 from library.services.search import SearchRequest, SearchService
req = SearchRequest( req = SearchRequest(
@@ -105,8 +106,7 @@ def _run_search(*, user, query, library_uid, library_type, collection_uid,
library_uid=library_uid, library_uid=library_uid,
library_type=library_type, library_type=library_type,
collection_uid=collection_uid, collection_uid=collection_uid,
workspace_id=workspace_id, resolved_libraries=resolved_libraries,
allowed_libraries=allowed_libraries,
search_types=search_types, search_types=search_types,
limit=limit, limit=limit,
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50), 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( def _load_chunk(
chunk_uid: str, chunk_uid: str,
workspace_id: str | None = None, resolved_libraries: list[str] | None,
allowed_libraries: list[str] | None = None,
) -> dict[str, Any]: ) -> 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 from neomodel import db
rows, _ = db.cypher_query( rows, _ = db.cypher_query(
"MATCH (l:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->" "MATCH (l:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->"
"(i:Item)-[:HAS_CHUNK]->(c:Chunk {uid: $uid}) " "(i:Item)-[:HAS_CHUNK]->(c:Chunk {uid: $uid}) "
"WHERE (($workspace_id IS NOT NULL AND l.workspace_id = $workspace_id) " "WHERE ($resolved_libraries IS NULL OR l.uid IN $resolved_libraries) "
" 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)) "
"RETURN c.uid, c.chunk_index, c.chunk_s3_key, " "RETURN c.uid, c.chunk_index, c.chunk_s3_key, "
"i.uid, i.title, l.library_type LIMIT 1", "i.uid, i.title, l.library_type LIMIT 1",
{ {
"uid": chunk_uid, "uid": chunk_uid,
"workspace_id": workspace_id, "resolved_libraries": resolved_libraries,
"allowed_libraries": allowed_libraries,
}, },
) )
if not rows: if not rows:

View File

@@ -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 from django.urls import path
@@ -7,6 +22,7 @@ from . import views
app_name = "mcp_server" app_name = "mcp_server"
urlpatterns = [ urlpatterns = [
# Self-service token dashboard (human-facing).
path("profile/mcp-tokens/", views.mcp_token_list, name="mcp-token-list"), 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/add/", views.mcp_token_create, name="mcp-token-create"),
path("profile/mcp-tokens/<int:pk>/", views.mcp_token_detail, name="mcp-token-detail"), path("profile/mcp-tokens/<int:pk>/", views.mcp_token_detail, name="mcp-token-detail"),

View File

@@ -28,12 +28,13 @@ def mcp_token_list(request: HttpRequest) -> HttpResponse:
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def mcp_token_create(request: HttpRequest) -> HttpResponse: def mcp_token_create(request: HttpRequest) -> HttpResponse:
if request.method == "POST": if request.method == "POST":
form = MCPTokenCreateForm(request.POST) form = MCPTokenCreateForm(request.POST, user=request.user)
if form.is_valid(): if form.is_valid():
token, plaintext = MCPToken.objects.create_token( token, plaintext = MCPToken.objects.create_token(
user=request.user, user=request.user,
name=form.cleaned_data["name"], name=form.cleaned_data["name"],
allowed_tools=form.cleaned_data.get("allowed_tools") or [], 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, expires_at=form.cleaned_data.get("expires_at") or None,
) )
return render( return render(
@@ -42,7 +43,7 @@ def mcp_token_create(request: HttpRequest) -> HttpResponse:
{"token": token, "plaintext": plaintext}, {"token": token, "plaintext": plaintext},
) )
else: else:
form = MCPTokenCreateForm() form = MCPTokenCreateForm(user=request.user)
return render(request, "mcp_server/tokens/create.html", {"form": form}) 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) token = get_object_or_404(MCPToken, pk=pk, user=request.user)
if request.method == "POST": if request.method == "POST":
form = MCPTokenEditForm(request.POST, instance=token) form = MCPTokenEditForm(request.POST, instance=token, user=request.user)
if form.is_valid(): if form.is_valid():
instance = form.save(commit=False) instance = form.save(commit=False)
instance.allowed_tools = form.cleaned_data.get("allowed_tools") or [] instance.allowed_tools = form.cleaned_data.get("allowed_tools") or []
instance.allowed_libraries = (
form.cleaned_data.get("allowed_libraries") or []
)
instance.save() instance.save()
messages.success(request, "MCP token updated.") messages.success(request, "MCP token updated.")
return redirect("mcp_server:mcp-token-detail", pk=token.pk) return redirect("mcp_server:mcp-token-detail", pk=token.pk)
else: else:
form = MCPTokenEditForm(instance=token) form = MCPTokenEditForm(instance=token, user=request.user)
return render( return render(
request, "mcp_server/tokens/edit.html", {"form": form, "token": token} request, "mcp_server/tokens/edit.html", {"form": form, "token": token}

View File

@@ -28,6 +28,11 @@ urlpatterns = [
path("library/", include("library.urls")), path("library/", include("library.urls")),
# LLM Manager # LLM Manager
path("llm/", include("llm_manager.urls")), 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("", 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

View File

@@ -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),
),
]