lasuite-docs-mcp
by Bone2510
README.md
# lasuite-docs-mcp
MCP server for the [LaSuite Docs](https://github.com/suitenumerique/docs) external API.
It exposes Docs operations (list/read/create/update/delete documents, versions,
favorites, current user) as MCP tools. Authentication uses the OIDC
**Authorization Code + PKCE** flow against **any** OpenID Connect provider, with a
refresh token cached in the OS keychain. Runs over **stdio** — no public hosting,
no inbound ports. The OIDC redirect is captured by a one-shot loopback server on
`127.0.0.1`.
```
Claude ──stdio──> lasuite-docs-mcp ──Bearer token──> Docs /external_api/v1.0
│
└── Auth Code + PKCE ──> OIDC provider (issues + introspects tokens)
```
Works with any standards-compliant OIDC provider (Keycloak, Authentik, Zitadel,
Auth0, Okta, Ory Hydra, …). Authentik is used as a worked example throughout,
marked **Example (Authentik)**.
---
## 1. Prerequisites
- Python ≥ 3.10
- A running LaSuite Docs instance with the resource server enabled (§2)
- An OIDC provider you control, able to register clients and run token
introspection (§3)
## 2. Configure LaSuite Docs (server side)
Docs must run as an OIDC **resource server**. Set in Docs' environment:
```env
OIDC_RESOURCE_SERVER_ENABLED=True
OIDC_OP_URL=<issuer URL of your OIDC provider>
OIDC_OP_INTROSPECTION_ENDPOINT=<provider token-introspection endpoint>
OIDC_RS_CLIENT_ID=<client id Docs uses to call introspection>
OIDC_RS_CLIENT_SECRET=<that client's secret>
OIDC_RS_AUDIENCE_CLAIM=<claim Docs checks for audience, e.g. aud>
OIDC_RS_ALLOWED_AUDIENCES=<value the MCP token must carry, see §3.4>
```
**Introspection-response format — important.** django-lasuite ships two resource
server backends:
| `OIDC_RS_BACKEND_CLASS` | Expects the introspection response as |
|--|--|
| `…JWTResourceServerBackend` (default) | a signed+encrypted JWT (RFC 9701 `application/token-introspection+jwt`) |
| `…ResourceServerBackend` | plain JSON (RFC 7662, standard) |
Most providers (Keycloak, Authentik, Zitadel, Auth0, …) return **plain JSON**.
If yours does, set:
```env
OIDC_RS_BACKEND_CLASS=lasuite.oidc_resource_server.backend.ResourceServerBackend
```
Leave the default only if your provider actually issues JWT-secured introspection
responses (e.g. ProConnect). Wrong choice → `400 Bad Request` / "improperly
configured" (see §9).
### 2.1 `EXTERNAL_API` action allowlist
`EXTERNAL_API` controls which resources/actions the resource-server API exposes.
Every tool maps to an action that must appear in this allowlist, **or the call
returns `403`** (and unknown nested resources return `404`). The default only
allows `documents: [list, retrieve, create, children]` and `users: [get_me]`,
so most tools need it widened. Full config covering every tool in this server:
```env
EXTERNAL_API={"documents":{"enabled":True,"actions":["list","retrieve","create","children","formatted_content","content_retrieve","content","partial_update","destroy","move","favorite","versions_list","trashbin","favorite_list","restore"]},"document_access":{"enabled":True,"actions":["list","retrieve","create","update","partial_update","destroy"]},"users":{"enabled":True,"actions":["get_me"]}}
```
> **Gotcha — Python literal, not JSON.** Docs parses this env var with
> `ast.literal_eval`, so booleans must be `True`/`False` (capitalized), **not**
> JSON `true`/`false`. JSON booleans crash Docs at boot with
> `ValueError: Cannot interpret dict value ... malformed node or string` (§9).
Action → tool mapping (non-obvious ones): `formatted_content` →
`get_document_content` (reads the body), `content` → `update_document_content`
(PATCH raw Yjs), `content_retrieve` → raw base64 Yjs GET, `children` →
parented `create_document`, `document_access.*` → the `*_document_access` tools.
### 2.2 Markdown upload (content on create)
The document resource has **no plain `content` field**. `create_document` with a
`markdown` body uploads it as a `.md` file that Docs converts to Yjs server-side.
That conversion needs:
```env
CONVERSION_UPLOAD_ENABLED=True
```
Without it, `create_document(markdown=...)` returns `400 file upload is not
allowed`. Reading the body back uses the `formatted_content` action (above).
> Docs validates every token by introspecting it and checking: the token is
> `active`, its **issuer** equals `OIDC_OP_URL`, and its audience claim is in
> `OIDC_RS_ALLOWED_AUDIENCES`. All three must line up (§3). Mismatch → 400/401.
## 3. Configure the OIDC provider
Register a client for the MCP server and make sure the tokens it issues pass
Docs' three checks. The concepts are provider-agnostic; the example shows where
each lives in Authentik.
### 3.1 Register the MCP client
Create an **OAuth2 / OIDC client** for the MCP server:
- **Client type**: public (PKCE, no secret) — recommended for a desktop/CLI tool.
Confidential (with secret) also works.
- **Grant type**: Authorization Code (+ refresh token).
- **Redirect URI**: `http://127.0.0.1:8765/callback` (exact match; change both
here and `OIDC_REDIRECT_URI` together).
- **Scopes**: `openid profile email` (+ an audience scope, §3.4).
> **Example (Authentik)** — Admin → Applications → Providers → *Create →
> OAuth2/OpenID Provider*. Set redirect URI `http://127.0.0.1:8765/callback`,
> client type Public, attach an Application. Note its issuer URL
> `https://<host>/application/o/<app-slug>/`.
### 3.2 Issuer must match Docs (`iss` check)
The token's `iss` claim **must equal** Docs' `OIDC_OP_URL` exactly. Easiest way:
let the MCP client and Docs trust the **same issuer**.
- If your provider has one global issuer (Keycloak realm, Zitadel, Auth0
tenant), this is automatic — point both at it.
- If your provider issues a **per-client/per-application issuer**, either reuse
the same provider/issuer Docs already trusts, or switch to a global/shared
issuer mode so both emit the same `iss`.
> **Example (Authentik)** — issuer is per-application by default
> (`…/o/<slug>/`), so a separate MCP application gets a *different* `iss` than the
> Docs login app → `InvalidClaimError: iss`. Fix: set the provider's **Issuer
> mode = "Same identifier (global)"** on both providers, or have the MCP use the
> same provider Docs trusts.
### 3.3 Introspection must accept the token (`active` check)
Docs introspects with `OIDC_RS_CLIENT_ID`/`SECRET`. Many providers only return
`active: true` when the **introspecting client is allowed to introspect that
token** (usually the token-issuing client, or one explicitly granted). If a
*different* client introspects, you get `active: false` → "user is not active".
Fix: set Docs' `OIDC_RS_CLIENT_ID`/`SECRET` to a client permitted to introspect
the MCP tokens — typically the **same client that issues them**, or configure
your provider's introspection/audience permissions accordingly.
> **Example (Authentik)** — cross-client introspection returns `active:false`.
> Set Docs `OIDC_RS_CLIENT_ID`/`SECRET` to the MCP application's client.
### 3.4 Audience (`OIDC_RS_ALLOWED_AUDIENCES` check)
The token must carry an audience value that Docs allows. Two approaches:
**A — provider emits the audience claim (recommended).**
Add a claim/mapper so issued tokens include the configured audience under the
claim Docs reads (`OIDC_RS_AUDIENCE_CLAIM`). Set
`OIDC_RS_ALLOWED_AUDIENCES` to that value.
> **Example (Authentik)** — Customisation → Property Mappings → *Create → Scope
> Mapping*: scope name `docs`, expression
> `return {"docs-aud": "<your-audience>"}`. Attach scope `docs` to the provider,
> add `docs` to `OIDC_SCOPE`, set Docs `OIDC_RS_AUDIENCE_CLAIM=docs-aud` and
> `OIDC_RS_ALLOWED_AUDIENCES=<your-audience>`.
> Simpler alternative: use the standard `aud` claim (= client id by default) —
> set `OIDC_RS_AUDIENCE_CLAIM=aud`, `OIDC_RS_ALLOWED_AUDIENCES=<mcp-client-id>`.
**B — `audience` request parameter.**
If your provider honors an `audience` auth-request param, set `OIDC_AUDIENCE`
(§4). Not all providers honor it (Authentik ignores it) — prefer A.
The claim name in `OIDC_RS_AUDIENCE_CLAIM` and the value in
`OIDC_RS_ALLOWED_AUDIENCES` must point at the **same thing**, and that thing must
be present in the token / introspection response.
## 4. Configure the MCP server
```bash
cp .env.example .env # then edit, or pass these via the MCP env block (§6)
```
| Var | Meaning |
|-----|---------|
| `DOCS_BASE_URL` | Docs host, e.g. `https://docs.example.org` |
| `DOCS_API_PREFIX` | API prefix, default `/external_api/v1.0` |
| `OIDC_OP_URL` | Your provider's issuer URL (server reads `…/.well-known/openid-configuration`) |
| `OIDC_CLIENT_ID` | the MCP client's id |
| `OIDC_CLIENT_SECRET` | empty for public/PKCE; set for a confidential client |
| `OIDC_SCOPE` | `openid profile email` (+ audience scope if using §3.4 A) |
| `OIDC_REDIRECT_URI` | `http://127.0.0.1:8765/callback` (must match the provider) |
| `OIDC_AUDIENCE` | only for §3.4 B; else leave empty |
## 5. Install
Pick one. All expose the `lasuite-docs-mcp` command.
**uv tool (recommended):**
```bash
uv tool install git+https://github.com/Bone2510/lasuite-docs-mcp # from GitHub
uv tool install . # local checkout
```
**pipx:**
```bash
pipx install git+https://github.com/Bone2510/lasuite-docs-mcp
```
**Editable dev install:**
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e .
```
**No install (uvx, run on demand):**
```bash
uvx --from git+https://github.com/Bone2510/lasuite-docs-mcp lasuite-docs-mcp
```
## 6. Register in Claude Code
Add to your MCP config (`~/.claude.json` / project `.mcp.json`). With `uv tool`/`pipx`
the command is on your PATH. The installed command runs from an arbitrary working
dir, so pass config via the `env` block (a `.env` is only picked up when the cwd
is the project dir):
```json
{
"mcpServers": {
"lasuite-docs": {
"command": "lasuite-docs-mcp",
"env": {
"DOCS_BASE_URL": "https://docs.example.org",
"OIDC_OP_URL": "https://auth.example.org/realms/main",
"OIDC_CLIENT_ID": "lasuite-docs-mcp",
"OIDC_CLIENT_SECRET": "...",
"OIDC_SCOPE": "openid profile email docs",
"OIDC_REDIRECT_URI": "http://127.0.0.1:8765/callback"
}
}
}
}
```
No-install variant:
```json
"command": "uvx",
"args": ["--from", "git+https://github.com/Bone2510/lasuite-docs-mcp", "lasuite-docs-mcp"]
```
On the **first tool call** the server opens your browser to the provider. After
login it captures the redirect on `127.0.0.1:8765`, stores the refresh token in
your OS keychain, and reuses/refreshes it silently afterward.
## 7. Test standalone
```bash
mcp dev lasuite_mcp/server.py # MCP inspector (interactive)
```
Call `get_current_user` first — it exercises the whole chain (login →
introspection → issuer → audience → user).
## 8. Available tools
**Read:** `get_current_user`, `list_documents`, `get_document` (metadata only),
`get_document_content` (body as markdown/html/json), `get_children`,
`list_versions`, `list_trashbin`, `list_favorites`.
**Write:** `create_document` (optional markdown body), `update_document` (title),
`update_document_content` (raw base64 Yjs), `delete_document`,
`restore_document`, `move_document`, `add_favorite`, `remove_favorite`.
**Accesses (sharing):** `list_document_accesses`, `get_document_access`,
`create_document_access`, `update_document_access`, `delete_document_access`.
> `get_document` never returns the body — Docs stores it as collaborative Yjs.
> Use `get_document_content` to read it (needs the `formatted_content` action,
> §2.1).
## 9. Troubleshooting
Server-side errors surface as a bare Django page; the real reason is in the
**Docs backend logs**. Common cases (all server-side OIDC config):
| Symptom | Cause / fix |
|--------|-------------|
| `401` `Resource Server is improperly configured` | RS backend failed to init — `OIDC_RS_CLIENT_ID`/`SECRET` missing/empty or `OIDC_RS_BACKEND_CLASS` invalid (§2). |
| `400` right after introspection, decode/decrypt error | Provider returns plain-JSON introspection but `JWTResourceServerBackend` is active → set `OIDC_RS_BACKEND_CLASS=…ResourceServerBackend` (§2). |
| `400` `Introspected user is not active` | Introspecting client may not introspect this token → set Docs RS creds to the token-issuing client (§3.3). |
| `400` `InvalidClaimError: iss` | Token `iss` ≠ Docs `OIDC_OP_URL` → align issuers / issuer mode (§3.2). |
| `401`/`400` audience | Token audience ∉ `OIDC_RS_ALLOWED_AUDIENCES`, or claim name mismatch → §3.4. |
| Browser never opens | stdio has no TTY; the auth URL is printed to stderr — open it manually. |
| `redirect_uri_mismatch` | Provider redirect URI ≠ `OIDC_REDIRECT_URI`. Must match exactly. |
| `404` on a tool | Your Docs version uses a different path — adjust in `lasuite_mcp/server.py`. |
| accesses/invitations `404` | Those scopes are disabled in Docs `EXTERNAL_API` by default — enable them (§2.1). |
| `403` on a tool | The action is not in the `EXTERNAL_API` allowlist — add it (§2.1). |
| `get_document` returns no body | By design — body is Yjs, use `get_document_content` (§8). |
| `400 file upload is not allowed` on create | `CONVERSION_UPLOAD_ENABLED` not set in Docs (§2.2). |
| Docs crashes at boot, `ValueError: Cannot interpret dict value … malformed node` | `EXTERNAL_API` uses JSON `true`/`false`; needs Python `True`/`False` (§2.1). |
| Port 8765 in use | Change `OIDC_REDIRECT_URI` port (and the provider redirect URI). |
## 10. Security notes
- PKCE (S256) is always used, even with a client secret.
- Redirect is loopback-only (`127.0.0.1`), random `state` checked against CSRF.
- Refresh token lives in the OS keychain (via `keyring`); if keyring is
unavailable it falls back to in-memory (re-login each start).
- Tokens are never logged.
## Notes / unknowns to verify against your instance
- **Content model** (verified against current Docs): the body is a Yjs/BlockNote
CRDT, not a field on the document resource. Read it via `get_document_content`
(`formatted-content` endpoint → markdown/html/json). Write paths: `create_document`
uploads a markdown file that Docs converts to Yjs (needs `CONVERSION_UPLOAD_ENABLED`);
`update_document_content` PATCHes a **raw base64 Yjs blob** (no markdown→Yjs path
exists for updates). `update_document` only changes the title.
- Exact sub-paths (`favorite-list/`, `move/`, `restore/`, `favorite/`,
`accesses/`) follow current conventions — confirm against your Docs
`EXTERNAL_API` routes.
TDQS
B3.3/5.0
Scored across 14 tools
Disambiguation5/5
Each tool has a clearly distinct purpose: create, delete, restore, move, update documents; listing different scopes (all, favorites, trashbin, versions); managing favorites; and user info. No overlapping functionality.
Naming Consistency5/5
All tool names follow a consistent verb_noun pattern in snake_case (e.g., create_document, list_favorites, get_document). No deviations or mixed conventions.
Tool Count5/5
14 tools are well-scoped for document management, covering CRUD, navigation, versioning, favorites, and trashbin operations. The count fits the 3-15 sweet spot.
Completeness4/5
The set covers most core operations, but lacks a dedicated endpoint to retrieve the full document content—only metadata is returned by get_document. Minor gap considering the domain.
Maintenance
ActivitySlowing
ResponsivenessNo issues