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
**bearer → resolved library set** abstraction. Every authenticated
Mnemosyne request resolves to a set of Library UIDs the caller may
read; the principal type (opaque `MCPToken`, Daedalus per-turn JWT,
team JWT) only determines how that set is derived.
Mnemosyne request resolves to a single ordered `resolved_libraries`
list of Library UIDs the caller may read; the principal type (opaque
`MCPToken`, Daedalus per-turn JWT, team JWT) only determines how that
list is derived. `Library.workspace_id` is a Daedalus content-routing
attribute used by the ingest and workspace-lifecycle APIs; it is **not**
consulted by the auth layer.
It also records the UX shift in Daedalus: **workspaces attach Teams
(Pallas instances), not individual agents**; the agent picker in chat
@@ -86,24 +89,48 @@ and the design collapses to two credential types.
### 3.3 Resolved-library abstraction
Mnemosyne's auth middleware populates a single
`resolved_libraries: set[str]` per request. Downstream code (search,
get_document, list_libraries, etc.) only reads that set; it does not
care where the set came from.
`resolved_libraries: list[str]` per request. Downstream code (search,
get_chunk, list_libraries, list_collections, list_items, …) only
reads that list; it does not care where it came from.
```
Bearer → classify → dispatch
├─ Opaque MCPToken → allowed_libraries M2M
├─ Opaque MCPToken → token.allowed_libraries (JSON list of UIDs)
├─ per-turn JWT → claims["libs"]
└─ team JWT (typ=team) → live DB: team.workspaces → libraries
(filtered by Library.workspace_id)
└─ team JWT (typ=team) → live DB join:
TeamWorkspaceAssignment.workspace_id
→ Library.workspace_id → Library.uid
resolved_libraries: set[str]
resolved_libraries: list[str]
downstream tools
```
Fail-closed: if the resolution produces an empty set, the request sees
no Libraries. There is no "empty means everything" path.
Fail-closed: if the resolution produces an empty list, the request
sees no Libraries. There is no "empty means everything" fallback.
#### 3.3.1 Retirement of the old three-branch scope clause
The pre-phase-2 search pipeline ran every Cypher query against a
`_WORKSPACE_SCOPE_CLAUSE` with three branches keyed on whether
`workspace_id` and/or `allowed_libraries` were set. Phase 2 removes
that clause entirely. Every authorization check collapses to:
```cypher
WHERE lib.uid IN $resolved_libraries
```
`Library.workspace_id` stays on the node as a Daedalus content-routing
attribute (used by the ingest API to find-or-create the per-workspace
Library, and by the workspace-lifecycle API to cascade-delete that
Library's contents). It is **not** an authorization axis and is not
consulted anywhere in the auth middleware, the MCP tool surface, or
the search service.
Admin-UI-initiated searches (Django staff logged into the Mnemosyne
admin / search page) materialize `resolved_libraries` explicitly as
"every Library UID the database contains" — the same mechanism used
today as a workaround, now the only code path.
---
@@ -133,9 +160,14 @@ class LibraryMembership(models.Model):
User can scope a Library into `MCPToken.allowed_libraries` iff they
have `owner` or `manager` role on it.
#### `MCPToken.allowed_libraries` (new M2M on existing model)
#### `MCPToken.allowed_libraries` (new field on existing model)
```python
allowed_libraries = models.ManyToManyField(Library, blank=True)
# JSON list of Library.uid strings. A real M2M isn't possible because
# Library lives in Neo4j (neomodel StructuredNode), not Django's ORM.
# The admin/dashboard form materializes the picker by querying
# Library.nodes and filtering to libraries where the token's user has
# an ``owner`` or ``manager`` LibraryMembership.
allowed_libraries = models.JSONField(default=list, blank=True)
```
Fail-closed: empty → token grants access to zero libraries.
Admin form filters the picker by the current user's owned/managed
@@ -254,6 +286,7 @@ def resolve_mcp_jwt(token_string: str) -> dict:
typ = claims.get("typ")
if typ == "team":
# No replay cache — team tokens are reused on every request.
# Validate sub=="team:<uuid>" shape; stash the uuid on claims.
pass
else:
if _remember_jti(jti, float(exp)):
@@ -262,19 +295,31 @@ def resolve_mcp_jwt(token_string: str) -> dict:
return claims
```
Downstream, the middleware branches:
Middleware populates `STATE_KEY_RESOLVED_LIBRARIES` per request:
```python
if claims.get("typ") == "team":
# Opaque MCPToken
resolved_libraries = list(token.allowed_libraries or [])
# Per-turn JWT (legacy; retires phase 4)
resolved_libraries = list(claims.get("libs") or [])
# Team JWT
team = Team.objects.get(id=uuid_from_sub(claims["sub"]),
active=True,
active_jti=claims["jti"])
resolved_libraries = _libraries_for_team(team)
else:
resolved_libraries = claims["libs"]
resolved_libraries = _libraries_for_team(team) # see below
```
`_libraries_for_team(team)` = all `Library` UIDs whose `workspace_id`
is in the team's `TeamWorkspaceAssignment` set.
`_libraries_for_team(team)` runs a single Cypher query against Neo4j:
```cypher
MATCH (l:Library)
WHERE l.workspace_id IN $workspace_ids
RETURN l.uid
```
where `$workspace_ids` is `list(team.workspace_assignments.values_list("workspace_id", flat=True))`.
---
@@ -283,13 +328,16 @@ is in the team's `TeamWorkspaceAssignment` set.
### 6.1 Third-party MCP client with opaque `MCPToken`
1. Client sends `Authorization: Bearer <plaintext>`.
2. Middleware hashes → looks up `MCPToken` → validates active/expired.
3. `resolved_libraries = token.allowed_libraries.values_list("uid")`.
3. `resolved_libraries = list(token.allowed_libraries or [])` — the
JSON list of Library UIDs the admin / dashboard granted at mint.
4. Fails closed if empty.
### 6.2 Daedalus chat per-turn JWT (legacy, retires Phase 4)
Unchanged from today. `iss=daedalus`, `typ` absent, `libs` carries the
workspace's user-managed libraries, `ws` carries the workspace id.
Mnemosyne validates against `MCPSigningKey` keyed by `kid`.
`iss=daedalus`, `typ` absent, `libs` carries the full library set
Daedalus pre-computed for that turn (the workspace's auto-Library
plus any user-managed extras), `ws` is present but no longer consulted
server-side. Middleware assigns `resolved_libraries = claims["libs"]`.
Mnemosyne validates the JWT against `MCPSigningKey` keyed by `kid`.
### 6.3 Agent team (Kottos / Mentor / Iolaus / post-migration Daedalus-chat)
1. Pallas sends `Authorization: Bearer <team-jwt>` (static, read from

View File

@@ -99,6 +99,17 @@ class ImageSerializer(serializers.Serializer):
class SearchRequestSerializer(serializers.Serializer):
"""Request body for ``/library/api/search/``.
Authorization scope is resolved server-side from the request's
Django session (this endpoint is gated by
``permission_classes=[IsAuthenticated]``), not from the request
body — see ``library.utils.all_library_uids`` and the unified
auth model in ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.
``library_uid`` / ``library_type`` / ``collection_uid`` are
filters inside that scope, not scope itself.
"""
query = serializers.CharField(max_length=2000)
library_uid = serializers.CharField(required=False, allow_blank=True)
library_type = serializers.ChoiceField(
@@ -106,7 +117,6 @@ class SearchRequestSerializer(serializers.Serializer):
required=False,
)
collection_uid = serializers.CharField(required=False, allow_blank=True)
workspace_id = serializers.CharField(required=False, allow_blank=True)
search_types = serializers.ListField(
child=serializers.ChoiceField(choices=["vector", "fulltext", "graph"]),
required=False,

View File

@@ -479,17 +479,23 @@ def search(request):
from django.conf import settings as django_settings
from library.services.search import SearchRequest, SearchService
from library.utils import all_library_uids
serializer = SearchRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
# This DRF endpoint is gated by ``IsAuthenticated`` against a
# Django session, not an MCP bearer. The session is trusted;
# expose every library to the request. MCP-bearer callers go
# through ``mcp_server`` and get a narrower ``resolved_libraries``
# materialized by the auth middleware.
search_request = SearchRequest(
query=data["query"],
library_uid=data.get("library_uid") or None,
library_type=data.get("library_type") or None,
collection_uid=data.get("collection_uid") or None,
workspace_id=data.get("workspace_id") or None,
resolved_libraries=all_library_uids(),
search_types=data.get("search_types", ["vector", "fulltext", "graph"]),
limit=data.get("limit", getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20)),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
@@ -511,6 +517,7 @@ def search_vector(request):
from django.conf import settings as django_settings
from library.services.search import SearchRequest, SearchService
from library.utils import all_library_uids
serializer = SearchRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -521,7 +528,7 @@ def search_vector(request):
library_uid=data.get("library_uid") or None,
library_type=data.get("library_type") or None,
collection_uid=data.get("collection_uid") or None,
workspace_id=data.get("workspace_id") or None,
resolved_libraries=all_library_uids(),
search_types=["vector"],
limit=data.get("limit", 20),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
@@ -542,6 +549,7 @@ def search_fulltext(request):
from django.conf import settings as django_settings
from library.services.search import SearchRequest, SearchService
from library.utils import all_library_uids
serializer = SearchRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -552,7 +560,7 @@ def search_fulltext(request):
library_uid=data.get("library_uid") or None,
library_type=data.get("library_type") or None,
collection_uid=data.get("collection_uid") or None,
workspace_id=data.get("workspace_id") or None,
resolved_libraries=all_library_uids(),
search_types=["fulltext"],
limit=data.get("limit", 20),
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),

View File

@@ -74,6 +74,11 @@ class Command(BaseCommand):
query=query,
library_uid=options["library_uid"] or None,
library_type=options["library_type"] or None,
# Unrestricted: the CLI is a shell-level operator tool; it
# bypasses the MCP bearer-resolver and sees every library.
# ``resolved_libraries=None`` is the "no auth clause" branch
# (see ``library/services/search.py::_RESOLVED_LIBRARIES_CLAUSE``).
resolved_libraries=None,
search_types=search_types,
limit=limit,
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),

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

View File

@@ -26,36 +26,47 @@ from .fusion import ImageSearchResult, SearchCandidate, reciprocal_rank_fusion
logger = logging.getLogger(__name__)
# Search-scope clause appended to every search Cypher query.
# Search-scope clause appended to every Cypher query.
#
# Three modes, picked structurally by which params are set:
# Authorization is expressed by the caller as a ``resolved_libraries``
# list — see §3.3 of ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md``. The
# MCP auth middleware materializes it from the bearer token (opaque
# MCPToken.allowed_libraries, per-turn JWT ``libs`` claim, or live
# ``Team → TeamWorkspaceAssignment → Library.workspace_id`` join) and
# trusted in-process callers (Django admin page, DRF session-auth'd
# search endpoint, ``manage.py search``) either pass the full set from
# ``library.utils.all_library_uids()`` or pass ``None`` to bypass the
# clause entirely.
#
# 1. ``workspace_id`` set, ``allowed_libraries`` empty → workspace-scoped.
# Returns ONLY content from libraries whose workspace_id matches.
# 2. ``workspace_id`` set + ``allowed_libraries`` non-empty → workspace
# PLUS the listed user-managed libraries (typical Phase-2 chat turn).
# 3. Both null → global. Returns ONLY libraries with no workspace_id
# (legacy opaque-token callers / dashboard).
# Two Cypher branches, picked by whether ``resolved_libraries`` is the
# parameter value:
#
# When ``allowed_libraries`` is non-empty alone (no workspace_id), it
# narrows results to those libraries.
_WORKSPACE_SCOPE_CLAUSE = (
" AND ("
"($workspace_id IS NOT NULL AND lib.workspace_id = $workspace_id) "
"OR ($allowed_libraries IS NOT NULL AND lib.uid IN $allowed_libraries) "
"OR ($workspace_id IS NULL AND $allowed_libraries IS NULL "
" AND lib.workspace_id IS NULL)"
")"
)
# * ``None`` — no clause; trusted in-process admin / CLI
# use. Returns every library the query hits.
# * non-empty list — ``WHERE lib.uid IN $resolved_libraries``.
# * empty list — fail-closed: no row passes because ``uid IN []``
# is false for every row (Cypher semantics).
#
# ``Library.workspace_id`` is NOT consulted here. It remains on the
# node as a Daedalus content-routing attribute (used by the ingest
# API and the workspace-lifecycle cascade) but it is not an auth axis.
_RESOLVED_LIBRARIES_CLAUSE = " AND ($resolved_libraries IS NULL OR lib.uid IN $resolved_libraries)"
@dataclass
class SearchRequest:
"""Parameters for a search query.
Scope is single-mode: a request is either workspace-scoped (workspace_id
set) or global (workspace_id is None). There is no parameter combination
that returns both workspace and global content in one call.
Authorization scope is expressed by ``resolved_libraries``:
* ``None`` — unrestricted (trusted admin / CLI callers).
* ``[]`` — fail-closed; zero results.
* ``["lib_x", …]`` — restrict to these Library UIDs.
``library_uid`` / ``library_type`` / ``collection_uid`` are
orthogonal *filters* supplied by the caller (e.g. "search only
within Fiction"); they narrow further inside whatever
``resolved_libraries`` already permits.
"""
query: str
@@ -64,11 +75,9 @@ class SearchRequest:
library_uid: Optional[str] = None
library_type: Optional[str] = None
collection_uid: Optional[str] = None
workspace_id: Optional[str] = None
# Phase-2 token claim: user-managed libraries the caller may include
# alongside their workspace's auto-library. Cypher uses ``IS NULL`` vs
# non-empty list to gate the second branch of the scope clause.
allowed_libraries: Optional[list[str]] = None
# Authorization-resolved Library UID set. See the module-level
# ``_RESOLVED_LIBRARIES_CLAUSE`` docstring for semantics.
resolved_libraries: Optional[list[str]] = None
search_types: list[str] = field(
default_factory=lambda: ["vector", "fulltext", "graph"]
)
@@ -82,19 +91,17 @@ class SearchRequest:
def __post_init__(self):
# Normalize empty strings to None so "" doesn't slip through as
# truthy at the Cypher boundary.
if self.workspace_id == "":
self.workspace_id = None
if self.library_uid == "":
self.library_uid = None
if self.library_type == "":
self.library_type = None
if self.collection_uid == "":
self.collection_uid = None
# Empty list collapses to None so the Cypher branch reads
# "$allowed_libraries IS NOT NULL" rather than "size > 0" — keeps
# the parameter binding straightforward and the predicate sargable.
if self.allowed_libraries is not None and len(self.allowed_libraries) == 0:
self.allowed_libraries = None
# resolved_libraries: preserve the distinction between None (no
# auth clause — trusted caller) and [] (fail-closed). Only
# normalize list contents to strip falsy entries.
if isinstance(self.resolved_libraries, list):
self.resolved_libraries = [u for u in self.resolved_libraries if u]
@dataclass
@@ -347,7 +354,7 @@ class SearchService:
AND ($library_type IS NULL OR lib.library_type = $library_type)
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
"""
+ _WORKSPACE_SCOPE_CLAUSE
+ _RESOLVED_LIBRARIES_CLAUSE
+ """
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
@@ -364,8 +371,7 @@ class SearchService:
"library_uid": request.library_uid,
"library_type": request.library_type,
"collection_uid": request.collection_uid,
"workspace_id": request.workspace_id,
"allowed_libraries": request.allowed_libraries,
"resolved_libraries": request.resolved_libraries,
}
try:
@@ -459,7 +465,7 @@ class SearchService:
AND ($library_type IS NULL OR lib.library_type = $library_type)
AND ($collection_uid IS NULL OR col.uid = $collection_uid)
"""
+ _WORKSPACE_SCOPE_CLAUSE
+ _RESOLVED_LIBRARIES_CLAUSE
+ """
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
@@ -476,8 +482,7 @@ class SearchService:
"library_uid": request.library_uid,
"library_type": request.library_type,
"collection_uid": request.collection_uid,
"workspace_id": request.workspace_id,
"allowed_libraries": request.allowed_libraries,
"resolved_libraries": request.resolved_libraries,
}
try:
@@ -520,7 +525,7 @@ class SearchService:
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
AND ($library_type IS NULL OR lib.library_type = $library_type)
"""
+ _WORKSPACE_SCOPE_CLAUSE
+ _RESOLVED_LIBRARIES_CLAUSE
+ """
RETURN chunk.uid AS chunk_uid, chunk.text_preview AS text_preview,
chunk.chunk_s3_key AS chunk_s3_key, chunk.chunk_index AS chunk_index,
@@ -537,8 +542,7 @@ class SearchService:
"top_k": top_k,
"library_uid": request.library_uid,
"library_type": request.library_type,
"workspace_id": request.workspace_id,
"allowed_libraries": request.allowed_libraries,
"resolved_libraries": request.resolved_libraries,
}
try:
@@ -593,7 +597,7 @@ class SearchService:
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
AND ($library_type IS NULL OR lib.library_type = $library_type)
"""
+ _WORKSPACE_SCOPE_CLAUSE
+ _RESOLVED_LIBRARIES_CLAUSE
+ """
WITH chunk, item, lib,
max(concept_score) AS score,
@@ -613,8 +617,7 @@ class SearchService:
"limit": request.fulltext_top_k,
"library_uid": request.library_uid,
"library_type": request.library_type,
"workspace_id": request.workspace_id,
"allowed_libraries": request.allowed_libraries,
"resolved_libraries": request.resolved_libraries,
}
try:
@@ -682,7 +685,7 @@ class SearchService:
WHERE ($library_uid IS NULL OR lib.uid = $library_uid)
AND ($library_type IS NULL OR lib.library_type = $library_type)
"""
+ _WORKSPACE_SCOPE_CLAUSE
+ _RESOLVED_LIBRARIES_CLAUSE
+ """
RETURN img.uid AS image_uid, img.image_type AS image_type,
img.description AS description, img.s3_key AS s3_key,
@@ -698,8 +701,7 @@ class SearchService:
"query_vector": query_vector,
"library_uid": request.library_uid,
"library_type": request.library_type,
"workspace_id": request.workspace_id,
"allowed_libraries": request.allowed_libraries,
"resolved_libraries": request.resolved_libraries,
}
try:

View File

@@ -21,3 +21,32 @@ def neo4j_available():
return True
except Exception:
return False
def all_library_uids() -> list[str]:
"""Return the UIDs of every ``Library`` node in Neo4j.
Used by trusted in-process callers — the Django admin HTML search
page, the ``/library/api/search/`` DRF endpoint (gated by Django
session auth) and the ``search`` management command — as the
``resolved_libraries`` argument to :class:`SearchRequest`. These
callers have already been authenticated/authorized at a coarser
layer (Django login / DRF session) and the unified auth middleware
(see ``mcp_server/auth.py``) is the one that resolves narrower
library sets for MCP bearer tokens.
Returns ``[]`` when Neo4j is unreachable. Callers that want the
unrestricted / "admin sees everything" semantics should feed this
result directly into ``SearchRequest.resolved_libraries``; callers
that want to distinguish "unrestricted" from "fail-closed empty"
must pass ``resolved_libraries=None`` for the former instead.
"""
if not neo4j_available():
return []
try:
from .models import Library
return [lib.uid for lib in Library.nodes.all() if lib.uid]
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
logger.warning("Failed to enumerate library UIDs for search: %s", exc)
return []

View File

@@ -141,40 +141,16 @@ _MAX_QUERY_IMAGE_BYTES = 8 * 1024 * 1024
def _all_library_uids() -> list[str]:
"""Return the UIDs of every Library node in Neo4j.
"""Legacy alias for :func:`library.utils.all_library_uids`.
The Django-side HTML search views (``search_page`` and
``library_search``) are admin/debug tools gated by ``@login_required``
against a local Django account; they are not exposed to external
MCP callers and have no workspace-scoping contract to honour.
The underlying ``SearchService`` always appends
``_WORKSPACE_SCOPE_CLAUSE`` to every Cypher query, and that clause's
default branch — "``$workspace_id`` IS NULL AND ``$allowed_libraries``
IS NULL" — only matches libraries whose own ``workspace_id`` is
``NULL``. So an authenticated admin searching from the UI would
silently miss every Daedalus-ingested document, because those
libraries always carry a non-null ``workspace_id``.
Passing the full set of library UIDs as ``allowed_libraries`` flips
the clause into its second branch
(``lib.uid IN $allowed_libraries``) which matches every library
regardless of ``workspace_id``. This reuses the exact mechanism
Phase-2 chat turns use for "user-managed libraries"; we're simply
granting the admin access to all of them. Returning ``[]`` is fine
when Neo4j is unreachable — ``SearchRequest.__post_init__``
collapses an empty list to ``None``, reverting to the legacy global
behaviour.
Kept here so existing tests that patch
``library.views._all_library_uids`` continue to work during the
Phase-2 refactor. New code should import ``all_library_uids``
directly from ``library.utils``.
"""
if not neo4j_available():
return []
try:
from .models import Library
from .utils import all_library_uids
return [lib.uid for lib in Library.nodes.all() if lib.uid]
except Exception as exc: # pragma: no cover - Neo4j unreachable paths
logger.warning("Failed to enumerate library UIDs for search: %s", exc)
return []
return all_library_uids()
@login_required
@@ -240,7 +216,7 @@ def library_search(request, uid):
query_image=image_bytes,
query_image_ext=image_ext,
library_uid=uid,
allowed_libraries=allowed,
resolved_libraries=allowed,
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),
@@ -830,11 +806,13 @@ def search_page(request):
query=query,
library_uid=library_uid or None,
library_type=library_type or None,
# Admin UI sees everything — workspace-scoped libraries
# included. Without this, ``_WORKSPACE_SCOPE_CLAUSE``
# falls back to its "global-only" branch and silently
# hides all Daedalus-ingested content.
allowed_libraries=_all_library_uids(),
# Admin UI is session-authenticated and sees every
# library, Daedalus-workspace-scoped or global.
# ``library.utils.all_library_uids`` materializes the
# full Library UID set as the request's
# ``resolved_libraries`` — see the unified auth model
# in ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3.
resolved_libraries=_all_library_uids(),
limit=getattr(django_settings, "SEARCH_DEFAULT_LIMIT", 20),
vector_top_k=getattr(django_settings, "SEARCH_VECTOR_TOP_K", 50),
fulltext_top_k=getattr(django_settings, "SEARCH_FULLTEXT_TOP_K", 30),

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 llm_manager.encryption
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('api_type', models.CharField(choices=[('openai', 'OpenAI Compatible'), ('azure', 'Azure OpenAI'), ('ollama', 'Ollama'), ('anthropic', 'Anthropic'), ('llama-cpp', 'Llama.cpp'), ('vllm', 'vLLM')], max_length=20)),
('api_type', models.CharField(choices=[('openai', 'OpenAI Compatible'), ('azure', 'Azure OpenAI'), ('ollama', 'Ollama'), ('anthropic', 'Anthropic'), ('llama-cpp', 'Llama.cpp'), ('vllm', 'vLLM'), ('bedrock', 'Amazon Bedrock')], max_length=20)),
('base_url', models.URLField()),
('api_key', llm_manager.encryption.EncryptedCharField(blank=True, default='', max_length=500)),
('is_active', models.BooleanField(default=True)),
@@ -64,6 +64,7 @@ class Migration(migrations.Migration):
('is_system_embedding_model', models.BooleanField(default=False, help_text='Mark this as the system-wide embedding model. Only ONE embedding model should have this set to True.')),
('is_system_chat_model', models.BooleanField(default=False, help_text='Mark this as the system-wide chat model. Only ONE chat model should have this set to True.')),
('is_system_reranker_model', models.BooleanField(default=False, help_text='Mark this as the system-wide reranker model. Only ONE reranker model should have this set to True.')),
('is_system_vision_model', models.BooleanField(default=False, help_text='Mark this as the system-wide vision model for image analysis. Only ONE vision model should have this set to True.')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('api', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='models', to='llm_manager.llmapi')),
@@ -82,7 +83,7 @@ class Migration(migrations.Migration):
('cached_tokens', models.PositiveIntegerField(default=0)),
('total_cost', models.DecimalField(decimal_places=6, default=Decimal('0'), help_text='Total cost in USD', max_digits=12)),
('session_id', models.CharField(blank=True, db_index=True, max_length=100)),
('purpose', models.CharField(choices=[('responder', 'RAG Responder'), ('reviewer', 'RAG Reviewer'), ('embeddings', 'Document Embeddings'), ('search', 'Vector Search'), ('reranking', 'Re-ranking'), ('multimodal_embed', 'Multimodal Embedding'), ('other', 'Other')], db_index=True, default='other', max_length=50)),
('purpose', models.CharField(choices=[('responder', 'RAG Responder'), ('reviewer', 'RAG Reviewer'), ('embeddings', 'Document Embeddings'), ('search', 'Vector Search'), ('reranking', 'Re-ranking'), ('multimodal_embed', 'Multimodal Embedding'), ('vision_analysis', 'Vision Analysis'), ('other', 'Other')], db_index=True, default='other', max_length=50)),
('request_metadata', models.JSONField(blank=True, help_text='Additional context (prompt, temperature, etc.)', null=True)),
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='usage_records', to='llm_manager.llmmodel')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='llm_usage', to=settings.AUTH_USER_MODEL)),
@@ -107,6 +108,10 @@ class Migration(migrations.Migration):
model_name='llmmodel',
index=models.Index(fields=['is_system_reranker_model', 'model_type'], name='llm_manager_is_syst_cc73c6_idx'),
),
migrations.AddIndex(
model_name='llmmodel',
index=models.Index(fields=['is_system_vision_model', 'model_type'], name='llm_manager_is_syst_d190bb_idx'),
),
migrations.AlterUniqueTogether(
name='llmmodel',
unique_together={('api', 'name')},

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)
class MCPTokenAdmin(admin.ModelAdmin):
form = MCPTokenAdminForm
list_display = [
"name",
"user",
"is_active",
"masked_token",
"library_count",
"expires_at",
"last_used_at",
"created_at",
@@ -19,7 +178,17 @@ class MCPTokenAdmin(admin.ModelAdmin):
readonly_fields = ["token_hash", "last_used_at", "created_at", "updated_at"]
fieldsets = (
(None, {"fields": ("user", "name", "is_active")}),
("Restrictions", {"fields": ("allowed_tools", "expires_at")}),
(
"Scope",
{
"fields": ("allowed_tools", "allowed_libraries", "expires_at"),
"description": (
"``allowed_libraries`` is fail-closed: empty = the token "
"can read no libraries. Picker shows libraries the token's "
"user has owner/manager membership on."
),
},
),
(
"Token (hashed at rest — plaintext is shown only once at creation)",
{"fields": ("token_hash",)},
@@ -31,6 +200,10 @@ class MCPTokenAdmin(admin.ModelAdmin):
def masked_token(self, obj):
return obj.get_masked_token()
@admin.display(description="Libraries")
def library_count(self, obj):
return len(obj.allowed_libraries or [])
def has_add_permission(self, request):
# Tokens must be created via the dashboard or management command
# so the plaintext can be surfaced to the user. Adding via admin
@@ -38,6 +211,11 @@ class MCPTokenAdmin(admin.ModelAdmin):
return False
# ---------------------------------------------------------------------------
# MCPSigningKey
# ---------------------------------------------------------------------------
@admin.register(MCPSigningKey)
class MCPSigningKeyAdmin(admin.ModelAdmin):
list_display = ["kid", "is_active", "created_at", "retired_at", "note"]
@@ -45,3 +223,107 @@ class MCPSigningKeyAdmin(admin.ModelAdmin):
search_fields = ["kid", "note"]
readonly_fields = ["created_at", "retired_at"]
fields = ["kid", "secret_hex", "is_active", "note", "created_at", "retired_at"]
# ---------------------------------------------------------------------------
# LibraryMembership
# ---------------------------------------------------------------------------
@admin.register(LibraryMembership)
class LibraryMembershipAdmin(admin.ModelAdmin):
list_display = ["user", "library_uid", "role", "created_at"]
list_filter = ["role"]
search_fields = ["user__username", "user__email", "library_uid"]
autocomplete_fields = ["user"]
readonly_fields = ["created_at"]
# ---------------------------------------------------------------------------
# Team + TeamWorkspaceAssignment
# ---------------------------------------------------------------------------
class TeamWorkspaceAssignmentInline(admin.TabularInline):
model = TeamWorkspaceAssignment
extra = 0
readonly_fields = ["created_at"]
fields = ["workspace_id", "created_at"]
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ["name", "id", "active", "workspace_count", "updated_at"]
list_filter = ["active"]
search_fields = ["name", "id"]
readonly_fields = [
"id",
"active_jti",
"created_at",
"updated_at",
]
fields = [
"id",
"name",
"active",
"active_jti",
"created_at",
"updated_at",
]
inlines = [TeamWorkspaceAssignmentInline]
actions = ["action_rotate_jwt", "action_deactivate"]
@admin.display(description="Workspaces")
def workspace_count(self, obj: Team) -> int:
return obj.workspace_assignments.count()
@admin.action(description="Rotate JWT (mint new, reveal once)")
def action_rotate_jwt(self, request, queryset):
revealed = []
for team in queryset:
if not team.active:
self.message_user(
request,
f"Skipped inactive team {team.name}",
level=messages.WARNING,
)
continue
team.rotate_jti()
try:
jwt_string = mint_team_jwt(team)
except TeamJWTError as exc:
self.message_user(
request,
f"Failed to mint JWT for {team.name}: {exc}",
level=messages.ERROR,
)
continue
revealed.append((team.name, jwt_string))
for name, jwt_string in revealed:
# Messages are surfaced in the admin banner. Operator is
# expected to copy the JWT immediately — there is no retrieval
# path afterward.
self.message_user(
request,
f"{name}: {jwt_string}",
level=messages.SUCCESS,
)
@admin.action(description="Deactivate (soft-delete)")
def action_deactivate(self, request, queryset):
for team in queryset:
team.deactivate()
self.message_user(
request,
f"Deactivated {queryset.count()} team(s). Their JWTs are now invalid.",
level=messages.SUCCESS,
)
@admin.register(TeamWorkspaceAssignment)
class TeamWorkspaceAssignmentAdmin(admin.ModelAdmin):
list_display = ["team", "workspace_id", "created_at"]
list_filter = ["team"]
search_fields = ["team__name", "workspace_id"]
readonly_fields = ["created_at"]

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.
Two token shapes are supported:
Three credential types are accepted — see
``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.2 for the full model:
* **Opaque** — the original `MCPToken` row. Long-lived, hashed at rest,
used by the dashboard / Claude Desktop / admin tooling. Plaintext
hashes to a row in `mcp_token`.
* **Signed JWT** — per-turn token minted by Daedalus. Carries
`{ws, libs}` claims. Validated entirely off the signature + claims;
no database lookup of the token itself, only of the signing key
(`MCPSigningKey`) referenced by the JWT header's `kid`.
1. **Opaque ``MCPToken``** (long-lived, hashed at rest). Authorization
scope is its ``allowed_libraries`` JSON list.
2. **Per-turn signed JWT** (``iss=daedalus``, ≤10 min, legacy — retires
in Phase 4 when Daedalus chat itself becomes a Pallas Team). Scope
is the ``libs`` claim.
3. **Team JWT** (``iss=mnemosyne``, ``typ=team``, 10-year lifetime).
Scope is resolved live by joining ``TeamWorkspaceAssignment`` rows
to Neo4j ``Library.workspace_id``.
Every branch populates a single :data:`STATE_KEY_RESOLVED_LIBRARIES`
value on the FastMCP context — a ``list[str]`` of Library UIDs the
downstream tools are permitted to read. Tools never consult claim
shapes; they read this list via
``mcp_server.context.get_mcp_resolved_libraries``.
Detection: a bearer with three base64url segments separated by dots and
a parseable `{"alg":"HS256","kid":...}` header is treated as JWT; anything
a parseable ``{"alg":"HS256","kid":...}`` header is treated as JWT; anything
else falls through to the opaque path.
"""
from __future__ import annotations
import base64
import hashlib
import json
import logging
import time
import uuid
from collections import OrderedDict
import jwt as pyjwt
@@ -32,20 +40,26 @@ from fastmcp.server.dependencies import get_http_request
from fastmcp.server.middleware import Middleware, MiddlewareContext
from .metrics import mcp_auth_failures_total
from .models import MCPSigningKey, MCPToken, hash_token
from .models import MCPSigningKey, MCPToken, Team, hash_token
logger = logging.getLogger(__name__)
STATE_KEY_USER = "mcp_user"
STATE_KEY_TOKEN = "mcp_token"
STATE_KEY_CLAIMS = "mcp_claims"
STATE_KEY_RESOLVED_LIBRARIES = "mcp_resolved_libraries"
# Permitted clock skew when validating JWT exp/iat. PyJWT applies this
# symmetrically as ``leeway``.
_JWT_LEEWAY_SECONDS = 30
# Mnemosyne is the audience; Daedalus is the only accepted issuer.
_JWT_ISS = "daedalus"
# Accepted JWT issuers.
#
# ``daedalus`` — per-turn tokens minted by Daedalus chat (legacy path,
# retires with Phase 4).
# ``mnemosyne`` — team tokens minted by this service. ``typ=team``
# distinguishes them from any future self-issued credential.
_JWT_ISS_VALUES = {"daedalus", "mnemosyne"}
# Bounded LRU of recently-seen jti values to discourage replay within
# a single Mnemosyne process. Real defense is short ``exp`` + HMAC; this
@@ -59,6 +73,10 @@ _JWT_ISS = "daedalus"
# ``exp`` has passed — that's the scenario PyJWT's own ``exp`` check
# would have already rejected, this is belt-and-braces for clock skew
# or a resurrected captured token.
#
# Team tokens (``typ=team``) bypass this cache entirely — they are
# reused on every request by design. Revocation for those tokens runs
# against the live ``Team`` row (``active`` + ``active_jti``).
_JTI_CACHE_MAX = 4096
_JTI_CACHE: "OrderedDict[str, float]" = OrderedDict()
@@ -159,8 +177,22 @@ def _remember_jti(jti: str, exp: float) -> bool:
def resolve_mcp_jwt(token_string: str) -> dict:
"""Validate a signed JWT and return its claims dict.
Raises ``MCPAuthError`` on any failure. Does not touch ``MCPToken`` —
JWTs are stateless and stored only as their signing key (``MCPSigningKey``).
Accepts both the legacy per-turn issuer (``iss=daedalus``) and the
new team issuer (``iss=mnemosyne``, ``typ=team``). The returned
claims dict is normalized so the middleware doesn't have to guess:
* ``claims["iss"]`` — as presented (``daedalus`` or ``mnemosyne``).
* ``claims["typ"]`` — ``"team"`` for team tokens, otherwise absent.
* ``claims["libs"]`` — per-turn only; normalized to ``list[str]``.
* ``claims["ws"]`` — per-turn only; may be ``None``. Not consulted
for authorization (kept for diagnostics).
* ``claims["team_id"]`` — team only; ``UUID`` parsed from
``sub == "team:<uuid>"``.
* ``claims["kid"]`` — copy of the JWT header's ``kid``.
Raises :class:`MCPAuthError` on any failure. The per-turn path runs
the ``_remember_jti`` replay check; the team path skips it (team
JWTs are intentionally reused across the token's lifetime).
"""
try:
unverified_header = pyjwt.get_unverified_header(token_string)
@@ -191,7 +223,9 @@ def resolve_mcp_jwt(token_string: str) -> dict:
algorithms=["HS256"],
leeway=_JWT_LEEWAY_SECONDS,
options={"require": ["exp", "iat", "iss", "sub", "jti"]},
issuer=_JWT_ISS,
# ``issuer=`` accepts ``str | Iterable[str]`` and raises
# ``InvalidIssuerError`` if the claim is outside the set.
issuer=list(_JWT_ISS_VALUES),
)
except pyjwt.ExpiredSignatureError:
raise MCPAuthError("Token has expired.")
@@ -212,19 +246,79 @@ def resolve_mcp_jwt(token_string: str) -> dict:
# ``require=["exp", ...]`` above guarantees presence + numeric; this
# is defence in depth against future PyJWT changes.
raise MCPAuthError("JWT exp must be numeric.")
typ = claims.get("typ")
if typ == "team":
# Team tokens: no replay cache, no ``libs`` or ``ws`` claims.
# Verify the ``sub`` shape and parse the embedded team UUID so
# the middleware doesn't have to re-parse it later.
sub = claims.get("sub")
if not isinstance(sub, str) or not sub.startswith("team:"):
raise MCPAuthError("Invalid MCP token.")
try:
claims["team_id"] = uuid.UUID(sub[len("team:"):])
except ValueError:
raise MCPAuthError("Invalid MCP token.")
else:
# Per-turn (legacy) path: replay-cache gate + normalize claims.
if _remember_jti(jti, float(exp)):
raise MCPAuthError("Token replay detected.")
# Normalize claim shapes: ws may be null/absent, libs default to [].
claims["ws"] = claims.get("ws") or None
libs = claims.get("libs") or []
if not isinstance(libs, list) or not all(isinstance(x, str) for x in libs):
raise MCPAuthError("JWT libs must be a list of strings.")
claims["libs"] = libs
claims["kid"] = kid
return claims
# --- Team-JWT library resolution ------------------------------------------
def _libraries_for_team(team_id: uuid.UUID, jti: str) -> list[str]:
"""Resolve a team token to the Library UIDs it may read.
Runs two cheap queries in sequence:
1. Fetch the ``Team`` row by UUID. Reject if it doesn't exist, is
inactive, or its ``active_jti`` doesn't match the incoming
``jti`` — this is how rotation / soft-delete revocation becomes
effective on the *next* request.
2. If active: read every ``TeamWorkspaceAssignment.workspace_id`` for
the team and translate them into Library UIDs via a single
Cypher query against Neo4j.
Returns an empty list when the team has no workspace assignments
(fail-closed — a team pointing at no workspaces sees no libraries).
"""
try:
team = Team.objects.get(pk=team_id)
except Team.DoesNotExist:
raise MCPAuthError("Invalid MCP token.")
if not team.active:
raise MCPAuthError("Token has been deactivated.")
if team.active_jti is None or str(team.active_jti) != jti:
raise MCPAuthError("Invalid MCP token.")
workspace_ids = list(
team.workspace_assignments.values_list("workspace_id", flat=True)
)
if not workspace_ids:
return []
from neomodel import db
rows, _ = db.cypher_query(
"MATCH (l:Library) WHERE l.workspace_id IN $ws RETURN l.uid",
{"ws": workspace_ids},
)
return [row[0] for row in rows if row and row[0]]
# --- Middleware ------------------------------------------------------------
@@ -234,7 +328,18 @@ class MCPAuthMiddleware(Middleware):
Listing tools/resources is permitted unauthenticated so clients can
discover the surface; calling a tool requires a valid token unless
MCP_REQUIRE_AUTH=False.
``MCP_REQUIRE_AUTH=False``.
On every authenticated call the middleware attaches four values to
the FastMCP ``Context`` state for downstream tools to consume via
:mod:`mcp_server.context`:
* ``STATE_KEY_USER`` — Django user.
* ``STATE_KEY_TOKEN`` — MCPToken row (opaque callers only).
* ``STATE_KEY_CLAIMS`` — JWT claims dict (JWT callers only).
* ``STATE_KEY_RESOLVED_LIBRARIES`` — authorization-resolved Library
UID list. Tools read this; they never read ``STATE_KEY_CLAIMS``
for authorization.
"""
# Tools that don't touch user data and must be callable without a token
@@ -261,6 +366,7 @@ class MCPAuthMiddleware(Middleware):
user = None
token = None
claims: dict | None = None
resolved_libraries: list[str] | None = None
if token_string:
try:
@@ -271,10 +377,16 @@ class MCPAuthMiddleware(Middleware):
user = await sync_to_async(
_resolve_jwt_actor, thread_sensitive=True
)(claims)
resolved_libraries = await sync_to_async(
_resolved_libraries_for_jwt, thread_sensitive=True
)(claims)
else:
user, token = await sync_to_async(
resolve_mcp_user, thread_sensitive=True
)(token_string)
# Opaque tokens store the Library UID list directly.
# Empty list = fail-closed; not "everything".
resolved_libraries = list(token.allowed_libraries or [])
except MCPAuthError as exc:
mcp_auth_failures_total.labels(reason=str(exc)).inc()
if require_auth:
@@ -283,7 +395,6 @@ class MCPAuthMiddleware(Middleware):
mcp_auth_failures_total.labels(reason="missing_token").inc()
raise PermissionError("Authentication required. Provide a Bearer token.")
tool_name = self._extract_tool_name(context)
if token and tool_name and not token.can_use_tool(tool_name):
mcp_auth_failures_total.labels(reason="tool_not_allowed").inc()
raise PermissionError(
@@ -298,6 +409,18 @@ class MCPAuthMiddleware(Middleware):
await fastmcp_ctx.set_state(STATE_KEY_TOKEN, token)
if claims is not None:
await fastmcp_ctx.set_state(STATE_KEY_CLAIMS, claims)
# Always publish resolved_libraries — None means "no auth
# information" and the tools treat that as fail-closed.
await fastmcp_ctx.set_state(
STATE_KEY_RESOLVED_LIBRARIES, resolved_libraries
)
logger.info(
"mcp_auth.resolved tool=%s principal=%s lib_count=%s",
tool_name,
self._describe_principal(user, token, claims),
"none" if resolved_libraries is None else len(resolved_libraries),
)
return await self._call_next_with_trace(tool_name, call_next, context)
@@ -334,6 +457,20 @@ class MCPAuthMiddleware(Middleware):
)
return result
@staticmethod
def _describe_principal(user, token, claims) -> str:
"""Compact, log-friendly principal summary. No PII beyond usernames."""
if claims is not None:
typ = claims.get("typ")
if typ == "team":
return f"team:{claims.get('team_id')}"
return f"jwt:{claims.get('sub')}"
if token is not None:
return f"mcptoken:{token.get_masked_token()}"
if user is not None:
return f"user:{user.username}"
return "anonymous"
@staticmethod
def _extract_token() -> str | None:
"""Pull the Bearer token off the current HTTP request, if any.
@@ -412,6 +549,10 @@ def _resolve_jwt_actor(claims: dict):
Returns the system service user (``MCP_JWT_SERVICE_USERNAME``, default
``daedalus-service``). The user must exist and be active. JWT tokens
are not tied to per-user accounts — claims encode all authorization.
Used for both per-turn and team JWTs. The service user is a hook for
usage accounting (LLMUsage / search metrics) and for the audit trail;
authorization does not depend on it.
"""
from django.contrib.auth import get_user_model
@@ -426,3 +567,14 @@ def _resolve_jwt_actor(claims: dict):
if not user.is_active:
raise MCPAuthError(f"JWT service user {username!r} is disabled.")
return user
def _resolved_libraries_for_jwt(claims: dict) -> list[str]:
"""Pick the right resolver branch for a validated JWT claims dict.
* ``typ == "team"`` → live lookup via :func:`_libraries_for_team`.
* otherwise → legacy ``claims["libs"]`` (per-turn JWT).
"""
if claims.get("typ") == "team":
return _libraries_for_team(claims["team_id"], claims["jti"])
return list(claims.get("libs") or [])

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 fastmcp.server.context import Context
from .auth import STATE_KEY_CLAIMS, STATE_KEY_TOKEN, STATE_KEY_USER
from .auth import (
STATE_KEY_CLAIMS,
STATE_KEY_RESOLVED_LIBRARIES,
STATE_KEY_TOKEN,
STATE_KEY_USER,
)
async def get_mcp_user(ctx: Context | None):
@@ -24,3 +49,23 @@ async def get_mcp_claims(ctx: Context | None) -> dict | None:
if ctx is None:
return None
return await ctx.get_state(STATE_KEY_CLAIMS)
async def get_mcp_resolved_libraries(ctx: Context | None) -> list[str] | None:
"""Return the authorization-resolved Library UID list for this request.
Semantics (matching ``SearchRequest.resolved_libraries``):
* ``None`` — no auth information available (e.g. the middleware did
not run, or the tool was invoked outside a request context). Tools
should treat this as fail-closed and refuse to return content.
* ``[]`` — the caller was authenticated but is scoped to zero
libraries. Tools MAY proceed and return empty results.
* ``["lib_x", …]`` — the caller may read exactly these libraries.
See ``docs/DAEDALUS_PALLAS_INTEGRATION_v1.md`` §3.3 for the unified
auth model that populates this value.
"""
if ctx is None:
return None
return await ctx.get_state(STATE_KEY_RESOLVED_LIBRARIES)

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
@@ -7,6 +22,7 @@ import functools
from django import forms
from .admin import _library_choices_for_user
from .models import MCPToken
@@ -51,10 +67,19 @@ class MCPTokenCreateForm(forms.Form):
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
help_text="Leave all unchecked to permit every tool.",
)
allowed_libraries = forms.MultipleChoiceField(
required=False,
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
help_text=(
"Libraries this token may read. Empty = zero libraries (fail-closed). "
"You only see libraries where you hold owner/manager membership."
),
)
def __init__(self, *args, **kwargs):
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields["allowed_tools"].choices = _tool_choices()
self.fields["allowed_libraries"].choices = _library_choices_for_user(user)
class MCPTokenEditForm(forms.ModelForm):
@@ -65,10 +90,18 @@ class MCPTokenEditForm(forms.ModelForm):
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
help_text="Leave all unchecked to permit every tool.",
)
allowed_libraries = forms.MultipleChoiceField(
required=False,
widget=forms.CheckboxSelectMultiple(attrs={"class": "checkbox checkbox-sm"}),
help_text=(
"Libraries this token may read. Empty = zero libraries (fail-closed). "
"You only see libraries where you hold owner/manager membership."
),
)
class Meta:
model = MCPToken
fields = ["name", "is_active", "expires_at", "allowed_tools"]
fields = ["name", "is_active", "expires_at", "allowed_tools", "allowed_libraries"]
widgets = {
"name": forms.TextInput(attrs={"class": "input input-bordered w-full"}),
"is_active": forms.CheckboxInput(attrs={"class": "toggle toggle-primary"}),
@@ -78,8 +111,18 @@ class MCPTokenEditForm(forms.ModelForm):
}),
}
def __init__(self, *args, **kwargs):
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
self.fields["allowed_tools"].choices = _tool_choices()
self.fields["allowed_libraries"].choices = _library_choices_for_user(
user or (self.instance.user if self.instance and self.instance.pk else None)
)
if self.instance and self.instance.pk:
self.fields["allowed_tools"].initial = self.instance.allowed_tools or []
self.fields["allowed_libraries"].initial = (
list(self.instance.allowed_libraries or [])
)
def clean_allowed_libraries(self):
value = self.cleaned_data.get("allowed_libraries") or []
return list(value)

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
from django.conf import settings
@@ -14,16 +14,46 @@ class Migration(migrations.Migration):
]
operations = [
migrations.CreateModel(
name='MCPSigningKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('kid', models.CharField(db_index=True, max_length=64, unique=True)),
('secret_hex', models.CharField(max_length=128)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('retired_at', models.DateTimeField(blank=True, null=True)),
('note', models.TextField(blank=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Team',
fields=[
('id', models.UUIDField(editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=200)),
('active', models.BooleanField(default=True)),
('active_jti', models.UUIDField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['name'],
},
),
migrations.CreateModel(
name='MCPToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(db_index=True, max_length=64, unique=True)),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('expires_at', models.DateTimeField(blank=True, null=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('allowed_tools', models.JSONField(blank=True, default=list)),
('allowed_libraries', models.JSONField(blank=True, default=list)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mcp_tokens', to=settings.AUTH_USER_MODEL)),
@@ -32,4 +62,32 @@ class Migration(migrations.Migration):
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='LibraryMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('library_uid', models.CharField(db_index=True, max_length=64)),
('role', models.CharField(choices=[('owner', 'Owner'), ('manager', 'Manager'), ('reader', 'Reader')], max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='library_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['library_uid', 'role'],
'indexes': [models.Index(fields=['library_uid', 'role'], name='mcp_server__library_f19411_idx')],
'constraints': [models.UniqueConstraint(fields=('user', 'library_uid'), name='unique_library_membership_per_user')],
},
),
migrations.CreateModel(
name='TeamWorkspaceAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('workspace_id', models.CharField(db_index=True, max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_assignments', to='mcp_server.team')),
],
options={
'ordering': ['team', 'workspace_id'],
'constraints': [models.UniqueConstraint(fields=('team', 'workspace_id'), name='unique_team_workspace')],
},
),
]

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 secrets
import uuid
from django.conf import settings
from django.db import models
@@ -11,8 +37,75 @@ def hash_token(plaintext: str) -> str:
return hashlib.sha256(plaintext.encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Library memberships
# ---------------------------------------------------------------------------
class LibraryMembership(models.Model):
"""Role of a user on a Neo4j-resident Library.
Library lives in Neo4j (``library.models.Library``, a neomodel
``StructuredNode``), so this table joins by the Library's
``uid`` string rather than a foreign key. Consumers that want
the Library's live state (name, description, workspace_id, …)
must look it up separately via ``Library.nodes.get(uid=…)``.
Roles are ordered (owner > manager > reader) but not hierarchical
in storage: a user with owner rights is represented by a single
row with ``role="owner"``, not multiple rows. Callers deciding
whether a user may *grant* a Library into an ``MCPToken`` should
check for ``role__in=("owner", "manager")``.
"""
class Role(models.TextChoices):
OWNER = "owner", "Owner"
MANAGER = "manager", "Manager"
READER = "reader", "Reader"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="library_memberships",
)
library_uid = models.CharField(max_length=64, db_index=True)
role = models.CharField(max_length=10, choices=Role.choices)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
# One (user, library_uid, role) triple per row. A user may not
# hold both ``manager`` and ``reader`` on the same library —
# callers must consolidate to the higher role.
constraints = [
models.UniqueConstraint(
fields=("user", "library_uid"),
name="unique_library_membership_per_user",
)
]
indexes = [
models.Index(fields=("library_uid", "role")),
]
ordering = ["library_uid", "role"]
def __str__(self): # pragma: no cover - trivial
return f"{self.user}{self.library_uid} ({self.role})"
# ---------------------------------------------------------------------------
# Opaque bearer tokens
# ---------------------------------------------------------------------------
class MCPTokenManager(models.Manager):
def create_token(self, *, user, name, allowed_tools=None, expires_at=None):
def create_token(
self,
*,
user,
name,
allowed_tools=None,
allowed_libraries=None,
expires_at=None,
):
"""Generate a new bearer token, store its hash, and return (instance, plaintext).
The plaintext is returned exactly once and is never persisted. Callers
@@ -25,6 +118,7 @@ class MCPTokenManager(models.Manager):
name=name,
token_hash=hash_token(plaintext),
allowed_tools=list(allowed_tools or []),
allowed_libraries=list(allowed_libraries or []),
expires_at=expires_at,
)
return instance, plaintext
@@ -36,6 +130,12 @@ class MCPToken(models.Model):
Tokens are hashed at rest (SHA-256, 64-char hex). Plaintext exists only in
memory at creation time, on the wire to the client, and in the user's own
storage. A leaked database backup discloses no usable credentials.
``allowed_libraries`` is a JSON list of Library ``uid`` strings. It is
the sole authorization axis for opaque-token callers: the auth
middleware materializes ``resolved_libraries = list(allowed_libraries)``
on every request. An empty list is fail-closed (the token sees nothing),
not an implicit "all".
"""
user = models.ForeignKey(
@@ -49,6 +149,12 @@ class MCPToken(models.Model):
expires_at = models.DateTimeField(null=True, blank=True)
last_used_at = models.DateTimeField(null=True, blank=True)
allowed_tools = models.JSONField(default=list, blank=True)
# JSON list of Library.uid strings. Fail-closed: empty → zero libraries.
# We cannot use a ``ManyToManyField(Library)`` because Library is a
# neomodel ``StructuredNode`` in Neo4j, not a Django ORM model.
allowed_libraries = models.JSONField(default=list, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -86,6 +192,11 @@ class MCPToken(models.Model):
return f"mcp_…{self.token_hash[:8]}"
# ---------------------------------------------------------------------------
# Signing keys
# ---------------------------------------------------------------------------
class MCPSigningKeyManager(models.Manager):
def active(self):
"""Active keys, newest first. Multiple may overlap during rotation."""
@@ -94,17 +205,26 @@ class MCPSigningKeyManager(models.Manager):
def by_kid(self, kid: str):
return self.filter(kid=kid).first()
def current(self):
"""Most recently seeded active key — used when minting new tokens."""
return self.filter(is_active=True).order_by("-created_at").first()
class MCPSigningKey(models.Model):
"""HMAC signing key for per-turn JWTs minted by Daedalus.
"""HMAC signing key used for every Mnemosyne JWT (``HS256``).
Per-turn tokens carry workspace + library claims and expire in minutes.
They are validated entirely off the signature + claims; no row is stored
per token. Only the *signing key* is persisted here, indexed by ``kid``.
Two populations of tokens share this keyring:
Rotation: seed a new active key, distribute the secret to Daedalus,
flip the old one ``is_active=False``. In-flight tokens with the retired
``kid`` fail at ``exp`` (bounded by the per-turn TTL).
* **Per-turn JWTs** (legacy, category 2) minted by Daedalus with
``exp`` ≤ 10 minutes. Retired in Phase 4.
* **Team JWTs** (category 3) minted by Mnemosyne itself with
``exp`` = 10 years. Signed with whichever ``MCPSigningKey`` was
``objects.current()`` at mint time.
Rotation: seed a new active key, distribute the secret to
Daedalus (for the per-turn path) and re-issue every team token
via ``POST /mcp_server/api/teams/{id}/rotate/`` (for the team
path), then flip the old one ``is_active=False``.
"""
kid = models.CharField(max_length=64, unique=True, db_index=True)
@@ -127,3 +247,87 @@ class MCPSigningKey(models.Model):
self.is_active = False
self.retired_at = timezone.now()
self.save(update_fields=["is_active", "retired_at"])
# ---------------------------------------------------------------------------
# Pallas teams
# ---------------------------------------------------------------------------
class Team(models.Model):
"""A Pallas deployment as seen by Mnemosyne.
``id`` is the Daedalus ``PallasInstance.id`` UUID and is stable
across re-deployments / host moves of the same Pallas instance.
``active_jti`` identifies the single currently-valid team JWT for
this team. On ``POST /rotate/`` we generate a new UUID here and
re-mint; the previous JWT is invalidated immediately because the
auth middleware compares the incoming ``jti`` against this value.
``active=False`` soft-deletes the team — every validation will
reject tokens whose ``sub`` resolves to this row, so revocation
survives restart without needing a cache purge.
"""
id = models.UUIDField(primary_key=True, editable=False)
name = models.CharField(max_length=200)
active = models.BooleanField(default=True)
active_jti = models.UUIDField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["name"]
def __str__(self):
suffix = "" if self.active else " [inactive]"
return f"{self.name}{suffix}"
def rotate_jti(self) -> uuid.UUID:
"""Install a fresh ``active_jti``. Returns the new UUID."""
self.active_jti = uuid.uuid4()
self.save(update_fields=["active_jti", "updated_at"])
return self.active_jti
def deactivate(self):
"""Soft-delete the team. All its JWTs stop validating on next request."""
self.active = False
self.active_jti = None
self.save(update_fields=["active", "active_jti", "updated_at"])
class TeamWorkspaceAssignment(models.Model):
"""Grant a team read access to a Daedalus workspace's libraries.
Queried live on every request via::
MATCH (l:Library)
WHERE l.workspace_id IN $workspace_ids
RETURN l.uid
so attach/detach is visible to subsequent requests without any
cache invalidation. ``workspace_id`` is a plain string (Daedalus
owns the namespace) rather than a foreign key, mirroring how
``library.models.IngestJob.library_uid`` references Neo4j state.
"""
team = models.ForeignKey(
Team,
on_delete=models.CASCADE,
related_name="workspace_assignments",
)
workspace_id = models.CharField(max_length=64, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("team", "workspace_id"),
name="unique_team_workspace",
)
]
ordering = ["team", "workspace_id"]
def __str__(self): # pragma: no cover - trivial
return f"{self.team}{self.workspace_id}"

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

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
@@ -10,18 +20,9 @@ from django.conf import settings
from django.core.files.storage import default_storage
from fastmcp.server.context import Context
from ..context import get_mcp_claims, get_mcp_user
from ..context import get_mcp_resolved_libraries, get_mcp_user
from ..metrics import record_tool_call
def _scope_from_claims(claims: dict | None,
arg_workspace_id: str | None) -> tuple[str | None, list[str] | None]:
"""Return (workspace_id, allowed_libraries) for a tool call. Token claims
trump tool args when present."""
if claims is not None:
return claims.get("ws"), claims.get("libs") or None
return arg_workspace_id, None
DEFAULT_SEARCH_TYPES = ["vector", "fulltext", "graph"]
@@ -36,11 +37,6 @@ def register_search_tools(mcp):
rerank: bool = True,
include_images: bool = True,
search_types: list[str] | None = None,
# workspace_id is system-injected by Daedalus's chat path. It is
# intentionally absent from the docstring so the calling LLM is
# never told it exists. Whatever value the LLM produces here is
# overwritten by Daedalus before the call reaches Mnemosyne.
workspace_id: str | None = None,
ctx: Context | None = None,
) -> dict[str, Any]:
"""Hybrid retrieval over Mnemosyne: vector + full-text + concept-graph
@@ -56,8 +52,7 @@ def register_search_tools(mcp):
score, and source. Also returns matching images when include_images=True.
"""
types = search_types or DEFAULT_SEARCH_TYPES
claims = await get_mcp_claims(ctx)
ws, libs = _scope_from_claims(claims, workspace_id)
resolved_libraries = await get_mcp_resolved_libraries(ctx)
with record_tool_call("search"):
user = await get_mcp_user(ctx)
return await sync_to_async(_run_search, thread_sensitive=True)(
@@ -66,8 +61,7 @@ def register_search_tools(mcp):
library_uid=library_uid,
library_type=library_type,
collection_uid=collection_uid,
workspace_id=ws,
allowed_libraries=libs,
resolved_libraries=resolved_libraries,
limit=limit,
rerank=rerank,
include_images=include_images,
@@ -77,8 +71,6 @@ def register_search_tools(mcp):
@mcp.tool
async def get_chunk(
chunk_uid: str,
# System-injected; deliberately absent from the docstring.
workspace_id: str | None = None,
ctx: Context | None = None,
) -> dict[str, Any]:
"""Fetch the full text of a chunk by its uid (typically obtained from `search`).
@@ -87,17 +79,26 @@ def register_search_tools(mcp):
item_uid, item_title, library_type, text. Use this when the 500-character
text_preview from `search` isn't enough.
"""
claims = await get_mcp_claims(ctx)
ws, libs = _scope_from_claims(claims, workspace_id)
resolved_libraries = await get_mcp_resolved_libraries(ctx)
with record_tool_call("get_chunk"):
return await sync_to_async(_load_chunk, thread_sensitive=True)(
chunk_uid, ws, libs
chunk_uid, resolved_libraries
)
def _run_search(*, user, query, library_uid, library_type, collection_uid,
workspace_id, allowed_libraries, limit, rerank, include_images,
search_types) -> dict[str, Any]:
def _run_search(
*,
user,
query,
library_uid,
library_type,
collection_uid,
resolved_libraries,
limit,
rerank,
include_images,
search_types,
) -> dict[str, Any]:
from library.services.search import SearchRequest, SearchService
req = SearchRequest(
@@ -105,8 +106,7 @@ def _run_search(*, user, query, library_uid, library_type, collection_uid,
library_uid=library_uid,
library_type=library_type,
collection_uid=collection_uid,
workspace_id=workspace_id,
allowed_libraries=allowed_libraries,
resolved_libraries=resolved_libraries,
search_types=search_types,
limit=limit,
vector_top_k=getattr(settings, "SEARCH_VECTOR_TOP_K", 50),
@@ -130,24 +130,26 @@ def _run_search(*, user, query, library_uid, library_type, collection_uid,
def _load_chunk(
chunk_uid: str,
workspace_id: str | None = None,
allowed_libraries: list[str] | None = None,
resolved_libraries: list[str] | None,
) -> dict[str, Any]:
"""Load a single chunk's full text, subject to the caller's library scope.
``resolved_libraries`` is enforced at the Cypher layer: ``None`` allows
any library (only used for trusted in-process callers — MCP middleware
never passes ``None`` for authenticated clients), ``[]`` matches zero
rows (fail-closed), a non-empty list restricts to those UIDs.
"""
from neomodel import db
rows, _ = db.cypher_query(
"MATCH (l:Library)-[:CONTAINS]->(:Collection)-[:CONTAINS]->"
"(i:Item)-[:HAS_CHUNK]->(c:Chunk {uid: $uid}) "
"WHERE (($workspace_id IS NOT NULL AND l.workspace_id = $workspace_id) "
" OR ($allowed_libraries IS NOT NULL AND l.uid IN $allowed_libraries) "
" OR ($workspace_id IS NULL AND $allowed_libraries IS NULL "
" AND l.workspace_id IS NULL)) "
"WHERE ($resolved_libraries IS NULL OR l.uid IN $resolved_libraries) "
"RETURN c.uid, c.chunk_index, c.chunk_s3_key, "
"i.uid, i.title, l.library_type LIMIT 1",
{
"uid": chunk_uid,
"workspace_id": workspace_id,
"allowed_libraries": allowed_libraries,
"resolved_libraries": resolved_libraries,
},
)
if not rows:

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

View File

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

View File

@@ -28,6 +28,11 @@ urlpatterns = [
path("library/", include("library.urls")),
# LLM Manager
path("llm/", include("llm_manager.urls")),
# MCP server (token dashboard at /profile/mcp-tokens/)
# MCP server — two surfaces:
# /profile/mcp-tokens/… — per-user self-service token dashboard (HTML, session auth)
# /mcp_server/api/… — Daedalus-facing team control plane (DRF, Basic auth)
# The MCP bearer-auth surface itself (tool calls) is mounted by
# mnemosyne.asgi at /mcp/ and is not routed here.
path("", include("mcp_server.urls")),
path("mcp_server/api/", include("mcp_server.api.urls")),
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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