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.
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— reusedOIDC_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-developmentservice) — 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-cursortargets) — generate each client's local, gitignored config pinningdocs-mcp-clienton 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_*).
- 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_URLfor Django).docs-mcpre-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). - JWT access tokens.
docs-mcpverifies tokens locally, so they must be JWTs signed with a key from the JWKS, carryingiss,exp,suband a space-separatedscopeclaim. Opaque access tokens are not supported bydocs-mcp(see Known limitations). Django does not have this constraint: it introspects the token in any case. - A claim identifying
docs-mcp. So that a token minted for another application of the same provider is rejected,docs-mcprequiresMCP_AUDIENCE_CLAIMto carry one ofMCP_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'sMCP_RESOURCE_URLif the provider honours RFC 8707 resource indicators (MCP clients send it as theresourceparameter). If adding the audience needs an extra scope, list it inMCP_EXTRA_SCOPESso it's advertised to clients.- the origin client, for providers that cannot add a custom audience:
MCP_AUDIENCE_CLAIM=client_id(RFC 9068) orazp, andMCP_ALLOWED_AUDIENCES=<the MCP client's client_id>.
- The Docs scopes.
docs:documents:search,docs:documents:readanddocs:documents:createmust be requestable and end up in the token'sscopeclaim: each tool checks its own scope, and Django requires at least one ofOIDC_RS_SCOPES. - An OAuth client for MCP clients. A public client (no secret, PKCE) whose
client_idis 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. - 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 containactive,iss,scopeand the claim named byOIDC_RS_AUDIENCE_CLAIM(defaultclient_id), whose value must be inOIDC_RS_ALLOWED_AUDIENCES— typically the MCP client'sclient_id. Providers returning signed/encrypted JWT introspection responses (RFC 9701) are supported throughOIDC_RS_BACKEND_CLASSand theOIDC_RS_*key settings, seeresource_server.md. - Stable
sub. Django resolves the Docs user by the token'ssub, so it must be the same subject Docs users log in with throughOIDC_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:
- Client scopes:
docs:documents:search,docs:documents:read,docs:documents:create(plain) anddocs-mcp(oneoidc-audience-mapperadding the audiencedocs-mcp). All four are optional scopes ondocs-mcp-clientonly, so the audience is added only when the client requests it — henceMCP_AUDIENCE_CLAIM=aud,MCP_ALLOWED_AUDIENCES=docs-mcpandMCP_EXTRA_SCOPES=docs-mcpinenv.d/development/mcp. 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, whereOIDC_RS_ALLOWED_AUDIENCES=docs-mcp-clientallow-lists it.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(realmimpress) - docs-mcp:
http://localhost:4455/mcp, health check athttp://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.
- Open the Inspector UI (
http://localhost:6274by default), set transport to Streamable HTTP, URL tohttp://localhost:4455/mcp, connect. - Log in as
impress/impress(or auser-e2e-*account, after grantingoffline_accessas above). Accept the scopes on Keycloak's consent screen. - List Tools should show the three tools with their Zod-derived JSON schemas.
- Call
search_documentswith{"query": "onboarding"},read_documentwith an accessible documentid, andcreate_documentwith{"title": "Test", "content": "# Hello"}. - Log in as a different user and call
read_documenton the first document — expect the tool call to fail (Django'sDocumentPermissiondenies 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-mcponly accepts JWT access tokens (verified locally against the JWKS). Providers issuing opaque access tokens would need token introspection indocs-mcptoo, 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 existingDocumentThrottle. - 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_idmust 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.