From 821b1bb1ccd90493cdbf93571bc95bac75faaf05 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 31 Jul 2026 22:37:33 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20basic=20mcp=20api=20?= =?UTF-8?q?for=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ -> read_document - POST /api/v1.0/mcp/documents -> create_document --- src/backend/core/mcp_api/__init__.py | 1 + src/backend/core/mcp_api/permissions.py | 31 +++ src/backend/core/mcp_api/serializers.py | 47 +++++ src/backend/core/mcp_api/urls.py | 14 ++ src/backend/core/mcp_api/views.py | 186 ++++++++++++++++++ src/backend/core/tests/mcp_api/__init__.py | 0 .../tests/mcp_api/test_mcp_api_documents.py | 173 ++++++++++++++++ src/backend/core/urls.py | 2 + src/backend/impress/settings.py | 10 + 9 files changed, 464 insertions(+) create mode 100644 src/backend/core/mcp_api/__init__.py create mode 100644 src/backend/core/mcp_api/permissions.py create mode 100644 src/backend/core/mcp_api/serializers.py create mode 100644 src/backend/core/mcp_api/urls.py create mode 100644 src/backend/core/mcp_api/views.py create mode 100644 src/backend/core/tests/mcp_api/__init__.py create mode 100644 src/backend/core/tests/mcp_api/test_mcp_api_documents.py diff --git a/src/backend/core/mcp_api/__init__.py b/src/backend/core/mcp_api/__init__.py new file mode 100644 index 000000000..a43d1adae --- /dev/null +++ b/src/backend/core/mcp_api/__init__.py @@ -0,0 +1 @@ +"""MCP-facing API endpoints for the Docs app.""" diff --git a/src/backend/core/mcp_api/permissions.py b/src/backend/core/mcp_api/permissions.py new file mode 100644 index 000000000..f2cc80b2a --- /dev/null +++ b/src/backend/core/mcp_api/permissions.py @@ -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 + ) diff --git a/src/backend/core/mcp_api/serializers.py b/src/backend/core/mcp_api/serializers.py new file mode 100644 index 000000000..f841e81e8 --- /dev/null +++ b/src/backend/core/mcp_api/serializers.py @@ -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) diff --git a/src/backend/core/mcp_api/urls.py b/src/backend/core/mcp_api/urls.py new file mode 100644 index 000000000..761591b2a --- /dev/null +++ b/src/backend/core/mcp_api/urls.py @@ -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/", + views.MCPReadDocumentView.as_view(), + ), +] diff --git a/src/backend/core/mcp_api/views.py b/src/backend/core/mcp_api/views.py new file mode 100644 index 000000000..d1e481b0c --- /dev/null +++ b/src/backend/core/mcp_api/views.py @@ -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/` — 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, + ) diff --git a/src/backend/core/tests/mcp_api/__init__.py b/src/backend/core/tests/mcp_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/core/tests/mcp_api/test_mcp_api_documents.py b/src/backend/core/tests/mcp_api/test_mcp_api_documents.py new file mode 100644 index 000000000..9440d4e4f --- /dev/null +++ b/src/backend/core/tests/mcp_api/test_mcp_api_documents.py @@ -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 diff --git a/src/backend/core/urls.py b/src/backend/core/urls.py index e89618650..5a3a3afad 100644 --- a/src/backend/core/urls.py +++ b/src/backend/core/urls.py @@ -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[0-9a-z-]*)/", include(document_related_router.urls), diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index ccec5e563..14b20732d 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -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(