Files
lasuite-docs/documentation/mcp_server.md
T
Anthony LC de94df3fe7 ♻️(mcp) make the docs-mcp server OIDC provider-agnostic
The Django side of the MCP auth chain is already
settings-driven, but the docs-mcp server was
tied to Keycloak: KEYCLOAK_* env vars and a
mandatory `aud=docs-mcp`, which needs a Keycloak
audience mapper.

The provider settings are now MCP_OIDC_ISSUER,
MCP_OIDC_JWKS_URL and MCP_OIDC_DISCOVERY_URL.
The audience check mirrors the backend's
OIDC_RS_AUDIENCE_CLAIM: MCP_AUDIENCE_CLAIM picks
the claim to check (`aud` by default, or
`client_id` / `azp`) and MCP_ALLOWED_AUDIENCES
lists the accepted values. The Keycloak-only
`docs-mcp` scope is no longer hardcoded in the
protected resource metadata; it moves to
MCP_EXTRA_SCOPES.

The documentation now lists what any OIDC provider
must provide, with the Keycloak realm kept as the
development example.
2026-09-25 12:07:59 +02:00

17 KiB

docs-mcp: MCP server for Docs

docs-mcp is a remote MCP server that exposes three tools — search_documents, read_document, create_document — backed by the Docs API. It is an OAuth-protected resource server: it authenticates the caller with an access token from your OIDC provider and forwards the caller's own token to Django, which stays the sole authority on document permissions.

Nothing in the auth chain is tied to a specific identity provider: both docs-mcp and the Django endpoints are configured through settings only. The development stack uses Keycloak (docker/auth/realm.json) as a ready-made example, see Example: the development Keycloak realm.

Architecture

MCP client (MCP Inspector, Claude Code, ...)
    |
    | 1. Authorization Code + PKCE against the OIDC provider, requesting scopes:
    |    openid docs:documents:search docs:documents:read docs:documents:create
    |    (+ any MCP_EXTRA_SCOPES, e.g. docs-mcp in dev)
    v
OIDC provider (dev: Keycloak, impress realm)
    |
    | 2. JWT access token identifying docs-mcp through MCP_AUDIENCE_CLAIM
    |    (dev: aud=docs-mcp, azp=docs-mcp-client)
    v
docs-mcp (TypeScript, Streamable HTTP, stateless)
    |
    | 3. Verifies the JWT locally (JWKS, iss, exp, audience claim), then forwards the *same*
    |    access token, unchanged, as a Bearer token
    |    (no token exchange; this server holds no credentials of its own)
    v
Django /api/v1.0/mcp/documents/* (core/mcp_api)
    |
    | 4. ResourceServerAuthentication (django-lasuite) introspects the token at the provider,
    |    checks its origin client is allow-listed (OIDC_RS_AUDIENCE_CLAIM in
    |    OIDC_RS_ALLOWED_AUDIENCES), resolves the Docs user by `sub`,
    |    DocumentViewSet's permissions/querysets decide
    v
PostgreSQL

Steps 3-4 are the same resource-server pattern the (separate, currently disabled) external_api feature uses: the MCP server forwards the caller's own token and Django introspects it, no token exchange. core/mcp_api reuses the same ResourceServerAuthentication backend, DocumentPermission class, and DocumentViewSet.get_queryset/get_object logic — see resource_server.md. Reading a document's content reuses the existing core.services.converter_services.Converter client that talks to the y-provider service to turn Yjs into Markdown.

There is deliberately no RFC 8693 (Token Exchange) step: local JWT validation (RFC 9068 + JWKS, RFC 7517) plus plain token forwarding (RFC 6750) needs no client secret, so this server holds no credentials of its own.

The bundled docs-mcp server is optional: the Django endpoints only rely on ResourceServerAuthentication and MCPResourceServerPermission, so any other MCP server (or client) forwarding a token that passes the OIDC_RS_* checks can use them.

Files

  • src/backend/core/mcp_api/ — the three Django endpoints and their permission/serializer classes.
  • src/backend/impress/settings.py, env.d/development/common — reused OIDC_OP_* / OIDC_RS_* resource server settings; MCP_READ_CONTENT_MAX_CHARS.
  • src/frontend/servers/mcp/ — the TypeScript server (Express + @modelcontextprotocol/sdk). Validates incoming tokens against the provider's JWKS (src/auth/jwtVerifier.ts) and forwards them to Django (src/docsApiClient.ts).
  • env.d/development/mcp, compose.yml (mcp-development service) — how it runs locally.
  • docker/auth/realm.json — the development Keycloak realm (clients and client scopes below).
  • Makefile (mcp-claude/mcp-codex/mcp-gemini/mcp-cursor targets) — generate each client's local, gitignored config pinning docs-mcp-client on first run (see "Connecting other MCP clients" below).

OIDC provider requirements

Any OpenID Connect provider works as long as it offers the following. Each item names the setting(s) it maps to on the docs-mcp side (MCP_*) and on the Django side (OIDC_*).

  1. Discovery and signing keys. An issuer URL, an OIDC discovery document (/.well-known/openid-configuration) and a JWKS endpoint (MCP_OIDC_ISSUER, MCP_OIDC_DISCOVERY_URL, MCP_OIDC_JWKS_URL; OIDC_OP_URL for Django). docs-mcp re-publishes the discovery document as its RFC 9728 protected resource metadata, so MCP clients find the authorization and token endpoints there. The provider must support the Authorization Code flow with PKCE (S256).
  2. JWT access tokens. docs-mcp verifies tokens locally, so they must be JWTs signed with a key from the JWKS, carrying iss, exp, sub and a space-separated scope claim. Opaque access tokens are not supported by docs-mcp (see Known limitations). Django does not have this constraint: it introspects the token in any case.
  3. A claim identifying docs-mcp. So that a token minted for another application of the same provider is rejected, docs-mcp requires MCP_AUDIENCE_CLAIM to carry one of MCP_ALLOWED_AUDIENCES. Pick whichever the provider can emit:
    • aud (the default): the provider adds a dedicated audience to the token — an audience mapper (Keycloak, dev setup: docs-mcp), an API identifier, or this server's MCP_RESOURCE_URL if the provider honours RFC 8707 resource indicators (MCP clients send it as the resource parameter). If adding the audience needs an extra scope, list it in MCP_EXTRA_SCOPES so it's advertised to clients.
    • the origin client, for providers that cannot add a custom audience: MCP_AUDIENCE_CLAIM=client_id (RFC 9068) or azp, and MCP_ALLOWED_AUDIENCES=<the MCP client's client_id>.
  4. The Docs scopes. docs:documents:search, docs:documents:read and docs:documents:create must be requestable and end up in the token's scope claim: each tool checks its own scope, and Django requires at least one of OIDC_RS_SCOPES.
  5. An OAuth client for MCP clients. A public client (no secret, PKCE) whose client_id is either pre-registered and pinned in each MCP client (what the dev setup does), or obtained through Dynamic Client Registration if the provider supports it. Its redirect URIs must cover the MCP clients' local callbacks.
  6. Token introspection for Django. A confidential client Django authenticates as (OIDC_RS_CLIENT_ID/OIDC_RS_CLIENT_SECRET) at the provider's introspection endpoint (OIDC_OP_INTROSPECTION_ENDPOINT). The introspection response must contain active, iss, scope and the claim named by OIDC_RS_AUDIENCE_CLAIM (default client_id), whose value must be in OIDC_RS_ALLOWED_AUDIENCES — typically the MCP client's client_id. Providers returning signed/encrypted JWT introspection responses (RFC 9701) are supported through OIDC_RS_BACKEND_CLASS and the OIDC_RS_* key settings, see resource_server.md.
  7. Stable sub. Django resolves the Docs user by the token's sub, so it must be the same subject Docs users log in with through OIDC_OP_*.

Configuration

docs-mcp (src/frontend/servers/mcp)

Variable Default Purpose
MCP_HOST / MCP_PORT 0.0.0.0 / 4455 bind address for the Express server
MCP_RESOURCE_URL required this server's /mcp URL, used as the OAuth "resource" and in PRM metadata
MCP_OIDC_ISSUER required issuer as seen by clients/tokens (iss claim check), e.g. http://localhost:8083/realms/impress
MCP_OIDC_JWKS_URL / MCP_OIDC_DISCOVERY_URL required JWKS and discovery endpoints, as reachable from this server (http://nginx:8083/... in Docker)
MCP_AUDIENCE_CLAIM aud token claim that must identify this server: aud, or e.g. client_id / azp
MCP_ALLOWED_AUDIENCES required comma-separated values accepted for MCP_AUDIENCE_CLAIM (at least one; the check cannot be disabled)
MCP_EXTRA_SCOPES empty comma-separated scopes advertised in the protected resource metadata on top of the docs:documents:* ones
DOCS_API_URL required Django's base URL, where the caller's token is forwarded

With MCP_AUDIENCE_CLAIM=aud the token's aud (a string or an array) must contain one of the allowed values; with any other claim, that claim's value must be one of them.

This server holds no credentials: it only reads the provider's public JWKS to verify token signatures and forwards the caller's token to Django. MCP_OIDC_ISSUER is split from the *_URL variables because this server may reach the provider through a different hostname than the one in the token's iss claim — in Docker, the internal nginx alias instead of the externally-visible localhost:8083 — the same split Django's own OIDC settings use (OIDC_OP_URL vs OIDC_OP_JWKS_ENDPOINT).

These variables are MCP_-prefixed rather than bare OIDC_* on purpose: Django already reads a large OIDC_OP_* / OIDC_RS_* family, and look-alike names with a different meaning (e.g. OIDC_AUDIENCE_CLAIM next to OIDC_RS_AUDIENCE_CLAIM) would be easy to mix up in a deployment sharing one configuration.

Django (src/backend)

The MCP endpoints reuse the resource server settings, see resource_server.md and env.md:

Setting Purpose for the MCP endpoints
OIDC_OP_URL expected issuer of the introspected token
OIDC_OP_INTROSPECTION_ENDPOINT where the forwarded token is introspected
OIDC_RS_CLIENT_ID / OIDC_RS_CLIENT_SECRET confidential client Django introspects as
OIDC_RS_SCOPES the token must carry at least one of them (docs:documents:search,docs:documents:read,docs:documents:create)
OIDC_RS_AUDIENCE_CLAIM introspection claim naming the token's origin client (default client_id)
OIDC_RS_ALLOWED_AUDIENCES accepted values for that claim (the MCP client's client_id)

OIDC_RS_* in impress/settings.py is process-wide, shared with the (currently disabled) external_api feature — there is only one resource-server identity per Django process. OIDC_RS_ALLOWED_AUDIENCES is a list, so a deployment running both would add each integration's client_id alongside the MCP client's, no code change needed. The MCP endpoints are mounted regardless of OIDC_RESOURCE_SERVER_ENABLED, which only gates external_api.

Example: the development Keycloak realm

The development stack ships a Keycloak realm with everything above declared in docker/auth/realm.json — no manual Admin Console steps required. It maps onto the requirements as follows:

  1. Client scopes: docs:documents:search, docs:documents:read, docs:documents:create (plain) and docs-mcp (one oidc-audience-mapper adding the audience docs-mcp). All four are optional scopes on docs-mcp-client only, so the audience is added only when the client requests it — hence MCP_AUDIENCE_CLAIM=aud, MCP_ALLOWED_AUDIENCES=docs-mcp and MCP_EXTRA_SCOPES=docs-mcp in env.d/development/mcp.
  2. docs-mcp-client (public): Authorization Code + PKCE (S256), no secret, no implicit flow, no direct grants. This is the client the MCP client authenticates the user as; the token it obtains is forwarded all the way to Django, where OIDC_RS_ALLOWED_AUDIENCES=docs-mcp-client allow-lists it.
  3. docs-api (confidential): the client Django authenticates as when calling Keycloak's token introspection endpoint (OIDC_RS_CLIENT_ID=docs-api/OIDC_RS_CLIENT_SECRET). No mappers, no special attributes.

The audience mapper is a Keycloak feature. With a provider that can't add a custom audience, drop the docs-mcp scope and allow-list the MCP client on the docs-mcp side as well, e.g. MCP_AUDIENCE_CLAIM=client_id (or azp: Keycloak user access tokens carry azp, not client_id) and MCP_ALLOWED_AUDIENCES=docs-mcp-client.

If you edit realm.json and want Keycloak to run exactly what the file says, drop and recreate it (imports are additive on an existing realm, not a full reset):

docker compose stop keycloak kc_postgresql
docker compose rm -f keycloak kc_postgresql
docker compose up -d keycloak

Two Keycloak-specific import pitfalls: client description fields are capped at 255 characters (a longer value crashes the entire import), and ProtocolMapperRepresentation entries don't accept a description field at all.

The MCP Inspector requests the offline_access scope unconditionally, which requires the authenticating user to hold the realm's offline_access role — grant it once per user via Admin Console → Users → user → Role mapping → Assign role → offline_access. This is an Inspector quirk, not something docs-mcp itself requires.

Running it

docs-mcp is one of the services make bootstrap / make run start (see the mcp-development service in compose.yml and the build-mcp / run-backend targets in the Makefile). To build or restart it on its own:

make build-mcp
docker compose up -d mcp-development
  • Django: http://localhost:8071
  • Keycloak: http://localhost:8083 (realm impress)
  • docs-mcp: http://localhost:4455/mcp, health check at http://localhost:4455/healthz

Testing the tools

With the MCP Inspector

cd src/frontend/servers/mcp
yarn mcp-inspector

This points the Inspector at http://localhost:4455/mcp over Streamable HTTP and pre-fills the static OAuth client (docs-mcp-client, no secret, scopes openid docs:documents:search docs:documents:read docs:documents:create docs-mcp). To configure it manually instead (e.g. against a different URL), run npx @modelcontextprotocol/inspector with no arguments and fill in the same values by hand in the OAuth Settings section before connecting — Dynamic Client Registration is not configured on this realm.

  1. Open the Inspector UI (http://localhost:6274 by default), set transport to Streamable HTTP, URL to http://localhost:4455/mcp, connect.
  2. Log in as impress / impress (or a user-e2e-* account, after granting offline_access as above). Accept the scopes on Keycloak's consent screen.
  3. List Tools should show the three tools with their Zod-derived JSON schemas.
  4. Call search_documents with {"query": "onboarding"}, read_document with an accessible document id, and create_document with {"title": "Test", "content": "# Hello"}.
  5. Log in as a different user and call read_document on the first document — expect the tool call to fail (Django's DocumentPermission denies it, surfaced as an error result).

Connecting other MCP clients

The dev realm does not expose Dynamic Client Registration (DCR) — docker/auth/realm.json only declares docs-mcp-client statically. Clients that support pinning a static client_id (no DCR, no client secret needed since it's a public client) can still connect. A make target generates each client's config on first run and launches it:

Client Config Makefile target
Claude Code .mcp.json make mcp-claude
Codex CLI .codex/config.toml make mcp-codex
Gemini CLI .gemini/settings.json make mcp-gemini
Cursor .cursor/mcp.json make mcp-cursor

Each config pins docs-mcp-client as the OAuth client and a fixed local callback port/URL (8090 for Claude Code, 8091 for Codex CLI, 8092 for Gemini CLI, Cursor's own fixed 8787). docs-mcp-client's redirectUris is ["*"] in this dev realm, so none of these need registering individually — don't carry that wildcard into a non-dev realm. None of these tools authenticate automatically on launch — trigger the OAuth login once per client (Claude Code: /mcp inside the session; Codex CLI: codex mcp login docs-mcp; Gemini CLI and Cursor: accept the OAuth prompt on first tool call). Codex CLI additionally requires the project to be marked as trusted before it reads .codex/config.toml.

These are external, fast-moving CLIs — if a make mcp-* target fails to connect, check that tool's current MCP/OAuth flag names against its own docs before assuming the Keycloak side is broken.

Running the tests

# Django
docker compose exec app-dev pytest core/tests/mcp_api

# TypeScript
docker compose exec mcp-development yarn test

Known limitations

  • docs-mcp only accepts JWT access tokens (verified locally against the JWKS). Providers issuing opaque access tokens would need token introspection in docs-mcp too, which is not implemented; Django's side already works with them since it always introspects.
  • No refresh-token handling in the MCP client flow; long sessions would need one.
  • No JWKS caching beyond jose's in-memory default, no retry/backoff on the OIDC provider or Django calls, no rate limiting beyond Django's existing DocumentThrottle.
  • Stateless mode means every request pays the cost of a new McpServer/transport instance — not tuned for high throughput.
  • No dynamic client registration in the dev realm: only pre-registered clients (docs-mcp-client) can connect. A production deployment needs a considered DCR policy or a small fleet of statically registered clients.
  • No structured logging/metrics/tracing beyond what Express/Django already provide by default.
  • The forwarded token is only audience-narrowed once (its origin client_id must be allow-listed). An Authorization Server supporting RFC 8693 token exchange could mint a Django-specific, shorter-lived token instead, at the cost of a provider-specific grant and a confidential client in the MCP server.