(backend) add basic mcp api for document

We added a basic MCP API to the backend.
This API is designed to facilitate integration with external
systems, providing a secure and efficient way to
access document data.

API endpoints include:
- POST /api/v1.0/mcp/documents/search  -> search_documents
- GET  /api/v1.0/mcp/documents/<id>    -> read_document
- POST /api/v1.0/mcp/documents         -> create_document
This commit is contained in:
Anthony LC
2026-09-06 14:19:57 +02:00
parent 3c1275c88d
commit 821b1bb1cc
9 changed files with 464 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""MCP-facing API endpoints for the Docs app."""
+31
View File
@@ -0,0 +1,31 @@
"""Permissions for the MCP-facing API endpoints."""
from django.conf import settings
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import permissions
class MCPResourceServerPermission(permissions.BasePermission):
"""
Require a resource-server access token whose origin client is allow-listed.
Mirrors `core.external_api.permissions.ResourceServerClientPermission`: the MCP server
forwards the caller's own Keycloak access token unchanged, Django introspects it, and the
token's origin client (`request.resource_server_token_audience`, the introspection
`client_id` by default) must be in `OIDC_RS_ALLOWED_AUDIENCES` — i.e. `docs-mcp-client`.
"""
def has_permission(self, request, view):
"""Check that authentication succeeded via the resource server with the right audience."""
if not isinstance(
request.successful_authenticator, ResourceServerAuthentication
):
return False
if not request.user.is_authenticated:
return False
return (
request.resource_server_token_audience in settings.OIDC_RS_ALLOWED_AUDIENCES
)
+47
View File
@@ -0,0 +1,47 @@
"""Serializers for the MCP-facing API endpoints.
These are deliberately separate from `core.api.serializers`: the MCP tools must only ever
see small, LLM-safe fields (id, title, excerpt, updated_at, converted text content) and never
abilities, access rows, link configuration or other internal metadata.
"""
from rest_framework import serializers
from core import models
# pylint: disable=abstract-method
class MCPSearchQuerySerializer(serializers.Serializer):
"""Validate the request body of the MCP search endpoint."""
query = serializers.CharField(required=True, allow_blank=False, max_length=512)
limit = serializers.IntegerField(
required=False, default=5, min_value=1, max_value=20
)
class MCPDocumentSummarySerializer(serializers.ModelSerializer):
"""Minimal document representation returned to the `search_documents` tool."""
children_ids = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.Document
fields = ["id", "title", "excerpt", "updated_at", "children_ids"]
read_only_fields = fields
def get_children_ids(self, instance):
"""Return the IDs of the document's non-deleted children."""
return list(
instance.get_children()
.filter(ancestors_deleted_at__isnull=True)
.values_list("id", flat=True)
)
class MCPDocumentCreateSerializer(serializers.Serializer):
"""Validate input for the `create_document` tool."""
title = serializers.CharField(required=True, allow_blank=False, max_length=255)
content = serializers.CharField(required=True, allow_blank=True)
parent_id = serializers.UUIDField(required=False, allow_null=True, default=None)
+14
View File
@@ -0,0 +1,14 @@
"""URL configuration for the MCP-facing API endpoints."""
from django.urls import path
from . import views
urlpatterns = [
path("mcp/documents/search", views.MCPSearchDocumentsView.as_view()),
path("mcp/documents", views.MCPCreateDocumentView.as_view()),
path(
"mcp/documents/<uuid:document_id>",
views.MCPReadDocumentView.as_view(),
),
]
+186
View File
@@ -0,0 +1,186 @@
"""MCP-facing API endpoints for the Docs app.
Backs the three MCP tools (`search_documents`, `read_document`, `create_document`). Rather than
subclassing/routing `DocumentViewSet` (which would also expose move/duplicate/versions/ai-*
actions), each view binds a plain `DocumentViewSet` instance to reuse its permission-checked
`get_queryset`/`get_object` — so Django's existing access rules (`DocumentPermission`,
ancestor-aware abilities) keep deciding who can see or create what.
"""
import base64
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import status
from rest_framework.exceptions import ValidationError as DRFValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from core.api.filters import ListDocumentFilter
from core.api.permissions import DocumentPermission
from core.api.throttling import DocumentThrottle
from core.api.viewsets import DocumentViewSet
from core.choices import RoleChoices
from core.models import Document, DocumentAccess
from core.services import mime_types
from core.services.converter_services import ConversionError, Converter
from core.utils.treebeard import create_tree_node_with_retry
from .permissions import MCPResourceServerPermission
from .serializers import (
MCPDocumentCreateSerializer,
MCPDocumentSummarySerializer,
MCPSearchQuerySerializer,
)
def _bind_document_viewset(request, *, detail, action, document_id=None):
"""Bind a real `DocumentViewSet` to `request` to reuse its queryset/permission logic."""
view = DocumentViewSet()
view.request = request
view.format_kwarg = None
view.detail = detail
view.action = action
view.kwargs = {"pk": str(document_id)} if document_id is not None else {}
return view
class MCPSearchDocumentsView(APIView):
"""`POST /api/v1.0/mcp/documents/search` — backs the `search_documents` tool."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [MCPResourceServerPermission, DocumentPermission]
throttle_classes = [DocumentThrottle]
throttle_scope = "document"
def post(self, request, *args, **kwargs):
"""Return small, LLM-safe summaries of documents matching `query`."""
params = MCPSearchQuerySerializer(data=request.data)
params.is_valid(raise_exception=True)
view = _bind_document_viewset(request, detail=False, action="list")
queryset = view.get_queryset()
filterset = ListDocumentFilter(
data={"q": params.validated_data["query"]},
queryset=queryset,
request=request,
)
if not filterset.is_valid():
raise DRFValidationError(filterset.errors)
queryset = filterset.filters["q"].filter(
queryset, filterset.form.cleaned_data["q"]
)
queryset = queryset.order_by("-updated_at")[: params.validated_data["limit"]]
serializer = MCPDocumentSummarySerializer(queryset, many=True)
return Response(serializer.data)
class MCPReadDocumentView(APIView):
"""`GET /api/v1.0/mcp/documents/<uuid:document_id>` — backs the `read_document` tool."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [MCPResourceServerPermission, DocumentPermission]
throttle_classes = [DocumentThrottle]
throttle_scope = "document"
def get(self, request, document_id, *args, **kwargs):
"""Return the document's title and Markdown content, converted from Yjs."""
view = _bind_document_viewset(
request, detail=True, action="retrieve", document_id=document_id
)
document = view.get_object()
content_text = ""
if document.content:
try:
content_text = Converter().ydoc.convert(
data=base64.b64decode(document.content),
content_type=mime_types.YJS,
accept=mime_types.MARKDOWN,
)
except ConversionError:
return Response(
{"detail": _("Could not convert document content.")},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
max_chars = settings.MCP_READ_CONTENT_MAX_CHARS
truncated = len(content_text) > max_chars
return Response(
{
"id": str(document.id),
"title": document.title,
"content": content_text[:max_chars],
"truncated": truncated,
"updated_at": document.updated_at,
}
)
class MCPCreateDocumentView(APIView):
"""`POST /api/v1.0/mcp/documents` — backs the `create_document` tool."""
authentication_classes = [ResourceServerAuthentication]
permission_classes = [MCPResourceServerPermission, DocumentPermission]
throttle_classes = [DocumentThrottle]
throttle_scope = "document"
def post(self, request, *args, **kwargs):
"""Create a document, at the root or under an accessible parent."""
serializer = MCPDocumentCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
title = serializer.validated_data["title"]
content = serializer.validated_data["content"]
parent_id = serializer.validated_data["parent_id"]
# Check permission on the parent (if any) before doing the costlier content
# conversion below, so a forbidden request never reaches the y-provider service.
parent = None
if parent_id is not None:
parent_view = _bind_document_viewset(
request, detail=True, action="children", document_id=parent_id
)
parent = parent_view.get_object()
try:
content_b64 = Converter().ydoc.convert(
data=content.encode("utf-8"),
content_type=mime_types.MARKDOWN,
accept=mime_types.YJS,
)
except ConversionError:
return Response(
{"detail": _("Could not convert document content.")},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if parent is not None:
document = create_tree_node_with_retry(
lambda: parent.add_child(
creator=request.user, title=title, content=content_b64
)
)
else:
document = create_tree_node_with_retry(
lambda: Document.add_root(
creator=request.user, title=title, content=content_b64
)
)
DocumentAccess.objects.create(
document=document, user=request.user, role=RoleChoices.OWNER
)
return Response(
{
"id": str(document.id),
"title": document.title,
"created_at": document.created_at,
},
status=status.HTTP_201_CREATED,
)
@@ -0,0 +1,173 @@
"""
Tests for the MCP-facing API endpoints.
These endpoints reuse `core.api.viewsets.DocumentViewSet`'s queryset/permission logic
(see `core/mcp_api/views.py`), so we mainly assert that the same access rules apply through
the new URLs, plus the audience check specific to the docs-api resource server token.
"""
from unittest.mock import patch
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
# pylint: disable=unused-argument
def test_mcp_documents_search_unauthenticated():
"""A request without a token must be rejected."""
response = APIClient().post("/api/v1.0/mcp/documents/search", {"query": "doc"})
assert response.status_code == 401
def test_mcp_documents_search_wrong_audience(
user_token, resource_server_backend, user_specific_sub, settings
):
"""A token whose audience isn't in OIDC_RS_ALLOWED_AUDIENCES must be rejected."""
settings.OIDC_RS_ALLOWED_AUDIENCES = ["not-docs-api"]
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post("/api/v1.0/mcp/documents/search", {"query": "doc"})
assert response.status_code == 403
def test_mcp_documents_search_returns_only_accessible_documents(
user_token, resource_server_backend, user_specific_sub
):
"""User A's search must only return documents user A can access."""
accessible = factories.DocumentFactory(
title="Accessible Alpha Report",
link_reach=models.LinkReachChoices.RESTRICTED,
)
factories.UserDocumentAccessFactory(
document=accessible, user=user_specific_sub, role=models.RoleChoices.READER
)
# Belongs to a different user (user B); user_specific_sub has no access to it.
factories.DocumentFactory(
title="Inaccessible Alpha Notes",
link_reach=models.LinkReachChoices.RESTRICTED,
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post("/api/v1.0/mcp/documents/search", {"query": "Alpha"})
assert response.status_code == 200
assert [doc["id"] for doc in response.data] == [str(accessible.id)]
assert set(response.data[0].keys()) == {
"id",
"title",
"excerpt",
"updated_at",
"children_ids",
}
def test_mcp_documents_read_forbidden_for_other_users_document(
user_token, resource_server_backend, user_specific_sub
):
"""User A must not be able to read user B's document."""
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get(f"/api/v1.0/mcp/documents/{document.id!s}")
assert response.status_code == 403
@patch("core.services.converter_services.YdocConverter.convert")
def test_mcp_documents_read_own_document(
mock_convert, user_token, resource_server_backend, user_specific_sub
):
"""User A must be able to read a document user A has access to."""
mock_convert.return_value = "# Hello\n\nWorld"
document = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=document, user=user_specific_sub, role=models.RoleChoices.READER
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.get(f"/api/v1.0/mcp/documents/{document.id!s}")
assert response.status_code == 200
assert response.data["content"] == "# Hello\n\nWorld"
assert set(response.data.keys()) == {
"id",
"title",
"content",
"truncated",
"updated_at",
}
def test_mcp_documents_create_forbidden_for_reader(
user_token, resource_server_backend, user_specific_sub
):
"""A reader must not be allowed to create a document under a parent document."""
parent = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=parent, user=user_specific_sub, role=models.RoleChoices.READER
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
"/api/v1.0/mcp/documents",
{"title": "Doc", "content": "Hello", "parent_id": str(parent.id)},
)
assert response.status_code == 403
@patch("core.services.converter_services.YdocConverter.convert")
def test_mcp_documents_create_allowed_for_editor(
mock_convert, user_token, resource_server_backend, user_specific_sub
):
"""An editor must be allowed to create a document under a parent document."""
mock_convert.return_value = factories.YDOC_HELLO_WORLD_BASE64
parent = factories.DocumentFactory(link_reach=models.LinkReachChoices.RESTRICTED)
factories.UserDocumentAccessFactory(
document=parent, user=user_specific_sub, role=models.RoleChoices.EDITOR
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
"/api/v1.0/mcp/documents",
{"title": "Doc", "content": "Hello", "parent_id": str(parent.id)},
)
assert response.status_code == 201
mock_convert.assert_called_once()
document = models.Document.objects.get(id=response.data["id"])
assert document.get_parent().id == parent.id
assert not models.DocumentAccess.objects.filter(document=document).exists()
@patch("core.services.converter_services.YdocConverter.convert")
def test_mcp_documents_create_root_creates_owner_access(
mock_convert, user_token, resource_server_backend, user_specific_sub
):
"""Creating a document with no parent creates a root document owned by the caller."""
mock_convert.return_value = factories.YDOC_HELLO_WORLD_BASE64
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {user_token}")
response = client.post(
"/api/v1.0/mcp/documents",
{"title": "Doc", "content": "Hello"},
)
assert response.status_code == 201
access = models.DocumentAccess.objects.get(document_id=response.data["id"])
assert access.user == user_specific_sub
assert access.role == models.RoleChoices.OWNER
+2
View File
@@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter
from core.api import viewsets
from core.external_api import viewsets as external_api_viewsets
from core.mcp_api.urls import urlpatterns as mcp_api_urls
# - Main endpoints
router = DefaultRouter()
@@ -66,6 +67,7 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*mcp_api_urls,
re_path(
r"^documents/(?P<resource_id>[0-9a-z-]*)/",
include(document_related_router.urls),
+10
View File
@@ -809,6 +809,16 @@ class Base(Configuration):
environ_prefix=None,
)
# MCP API
# MCP_READ_CONTENT_MAX_CHARS is the maximum number of characters
# that can be read from a document by the MCP API.
MCP_READ_CONTENT_MAX_CHARS = values.PositiveIntegerValue(
default=20000,
environ_name="MCP_READ_CONTENT_MAX_CHARS",
environ_prefix=None,
)
# External API Configuration
# Configure available routes and actions for external_api endpoints
EXTERNAL_API = values.DictValue(