mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-10 11:47:52 +02:00
✨(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:
@@ -0,0 +1 @@
|
||||
"""MCP-facing API endpoints for the Docs app."""
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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(),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user