(mcp) add the docs-mcp server

Add `src/frontend/servers/mcp`, a stateless TypeScript (Express +
`@modelcontextprotocol/sdk`) MCP server exposing `search_documents`,
`read_document` and `create_document` over Streamable HTTP.

It is an OAuth resource server holding no credentials of its own: it
verifies the caller's Keycloak token against the realm JWKS
(`src/auth/jwtVerifier.ts`), checks the `docs-mcp` audience, then
forwards the same token unchanged to Django's
`/api/v1.0/mcp/documents/*` endpoints (`src/docsApiClient.ts`),
which stays the sole authority on document permissions.
No token exchange.

Ship the `mcp-development` compose service, its `env.d/development/mcp`
env file, a `build-mcp` Makefile helper, and
`documentation/mcp_server.md` describing the full flow and
how to connect MCP clients.
This commit is contained in:
Anthony LC
2026-09-11 15:23:34 +02:00
parent 5d74c10aa0
commit f9e30f1ccc
27 changed files with 1388 additions and 8 deletions
+1
View File
@@ -9,6 +9,7 @@ and this project adheres to
### Added
- 🔧(backend) fine tune redis cache options
- ✨(mcp) add the docs-mcp server #2646
### Changed
+8
View File
@@ -78,6 +78,7 @@ create-env-local-files:
@touch env.d/development/postgresql.local
@touch env.d/development/kc_auth.local
@touch env.d/development/kc_postgresql.local
@touch env.d/development/mcp.local
.PHONY: create-env-local-files
generate-secret-keys:
@@ -191,6 +192,7 @@ build: cache ?=
build: ## build the project containers
@$(MAKE) build-backend cache=$(cache)
@$(MAKE) build-yjs-provider cache=$(cache)
@$(MAKE) build-mcp cache=$(cache)
@$(MAKE) build-frontend cache=$(cache)
.PHONY: build
@@ -209,6 +211,11 @@ build-frontend: ## build the frontend container
@$(COMPOSE) build frontend-development $(cache)
.PHONY: build-frontend
build-mcp: cache ?=
build-mcp: ## build the mcp container
@$(COMPOSE) build mcp-development $(cache)
.PHONY: build-mcp
build-e2e: cache ?=
build-e2e: ## build the e2e container
@$(MAKE) build-backend cache=$(cache)
@@ -234,6 +241,7 @@ run-backend: ## Start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d y-provider-development
@$(COMPOSE) up --force-recreate -d y-provider-development-converter
@$(COMPOSE) up --force-recreate -d mcp-development
@$(COMPOSE) up --force-recreate -d nginx
.PHONY: run-backend
+23
View File
@@ -214,6 +214,29 @@ services:
y-provider-development:
condition: service_started
mcp-development:
user: ${DOCKER_USER:-1000}
build:
context: .
dockerfile: ./src/frontend/servers/mcp/Dockerfile
target: mcp-development
image: impress:mcp-development
restart: unless-stopped
env_file:
- env.d/development/mcp
- env.d/development/mcp.local
ports:
- "4455:4455"
volumes:
- ./src/frontend/:/home/frontend
- /home/frontend/node_modules
- /home/frontend/servers/mcp/node_modules
depends_on:
app-dev:
condition: service_started
keycloak:
condition: service_healthy
kc_postgresql:
image: postgres:14.3
healthcheck:
+221
View File
@@ -0,0 +1,221 @@
# docs-mcp: MCP server for Docs
`docs-mcp` is a remote [MCP](https://modelcontextprotocol.io) 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 via Keycloak and forwards
the caller's own token to Django, which stays the sole authority on document permissions.
## Architecture
```text
MCP client (MCP Inspector, Claude Code, ...)
|
| 1. Authorization Code + PKCE, requesting scopes:
| openid docs:documents:search docs:documents:read docs:documents:create docs-mcp
v
Keycloak (impress realm)
|
| 2. Access token, aud=docs-mcp (only because the docs-mcp scope was requested),
| azp=docs-mcp-client
v
docs-mcp (TypeScript, Streamable HTTP, stateless)
|
| 3. 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 Keycloak,
| checks its origin client is allow-listed (OIDC_RS_ALLOWED_AUDIENCES=docs-mcp-client),
| 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`](./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.
## Files
- `docker/auth/realm.json` — Keycloak clients (`docs-mcp-client` public, `docs-api`
confidential/introspection) and client scopes (`docs:documents:search`,
`docs:documents:read`, `docs:documents:create`, `docs-mcp`).
- `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_RS_*` resource
server settings (`OIDC_RS_CLIENT_ID=docs-api` for introspection,
`OIDC_RS_ALLOWED_AUDIENCES=docs-mcp-client`); `MCP_READ_CONTENT_MAX_CHARS`.
- `src/frontend/servers/mcp/` — the TypeScript server (Express + `@modelcontextprotocol/sdk`).
Validates incoming tokens against Keycloak'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.
- `.mcp.json`, `.codex/config.toml`, `.gemini/settings.json`, `.cursor/mcp.json` — per-client
config pinning `docs-mcp-client` for Claude Code, Codex CLI, Gemini CLI, and Cursor (see
"Connecting other MCP clients" below).
## Environment variables (`src/frontend/servers/mcp`)
| Variable | Purpose |
| --- | --- |
| `MCP_HOST` / `MCP_PORT` | bind address for the Express server |
| `MCP_RESOURCE_URL` | this server's `/mcp` URL, used as the OAuth "resource" and in PRM metadata |
| `KEYCLOAK_ISSUER` | issuer as seen by clients/tokens (`iss` claim check), e.g. `http://localhost:8083/realms/impress` |
| `KEYCLOAK_JWKS_URL` / `KEYCLOAK_DISCOVERY_URL` | JWKS and discovery endpoints, reachable from inside Docker (`http://nginx:8083/...`) |
| `DOCS_API_URL` | Django's base URL, where the caller's token is forwarded |
| `MCP_AUDIENCE` | audience this server requires on incoming tokens (`docs-mcp`) |
This server holds no credentials: it only reads Keycloak's public JWKS to verify token
signatures and forwards the caller's token to Django. `KEYCLOAK_ISSUER` is split from the
`*_URL` variables because, in Docker, this server reaches Keycloak through the internal `nginx`
alias, while the token's `iss` claim (and the metadata shown to external clients) must use the
externally-visible `localhost:8083` hostname — the same split Django's own OIDC settings use
(`OIDC_OP_URL` vs `OIDC_OP_JWKS_ENDPOINT`).
## Keycloak configuration
All of it is declared in `docker/auth/realm.json` — no manual Admin Console steps required.
There is no token exchange, so there are only two clients:
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.
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.
3. **`docs-api`** (confidential): the client Django authenticates as when calling Keycloak's
token introspection endpoint (`OIDC_RS_CLIENT_ID`/`OIDC_RS_CLIENT_SECRET`). No mappers, no
special attributes.
Django trusts the forwarded token only after `ResourceServerAuthentication` introspects it at
Keycloak on every call, checking it's `active`, its `iss` matches `OIDC_OP_URL`, it carries an
`OIDC_RS_SCOPES` scope, and its origin client (`OIDC_RS_AUDIENCE_CLAIM`, default `client_id`) is
in `OIDC_RS_ALLOWED_AUDIENCES` (`docs-mcp-client`). A token minted for another client, or
missing the MCP scopes, cannot reach these endpoints.
`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 `docs-mcp-client`, no code change needed.
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):
```bash
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:
```bash
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
```bash
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
This 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. Each has a
project-level config file and a `make` target that 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,
which is why that port needs to be a registered redirect URI on `docs-mcp-client` (see
`docker/auth/realm.json`): `8090` (Claude Code), `8091` (Codex CLI), `8092` (Gemini CLI), and
Cursor's own fixed `8787` (not configurable on Cursor's side). 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
```bash
# Django
docker compose exec app-dev pytest core/tests/mcp_api
# TypeScript
docker compose exec mcp-development yarn test
```
## Known limitations
- 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 Keycloak 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: 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.
+19
View File
@@ -0,0 +1,19 @@
# docs-mcp: remote MCP server for Docs.
# See documentation/mcp_server.md.
MCP_HOST=0.0.0.0
MCP_PORT=4455
MCP_RESOURCE_URL=http://localhost:4455/mcp
# Keycloak: KEYCLOAK_ISSUER is the externally-visible issuer (used to validate the `iss` claim
# and in metadata); the *_URL vars are the network-reachable endpoints from inside Docker.
# This server only reads Keycloak's JWKS and discovery document — it holds no credentials and
# performs no token exchange; it forwards the caller's access token to Django as-is.
KEYCLOAK_ISSUER=http://localhost:8083/realms/impress
KEYCLOAK_JWKS_URL=http://nginx:8083/realms/impress/protocol/openid-connect/certs
KEYCLOAK_DISCOVERY_URL=http://nginx:8083/realms/impress/.well-known/openid-configuration
# Django is reached directly (not through nginx, which only proxies to Keycloak on :8083).
DOCS_API_URL=http://app-dev:8000
MCP_AUDIENCE=docs-mcp
+4 -3
View File
@@ -17,19 +17,20 @@
"APP_E2E": "yarn workspace app-e2e",
"I18N": "yarn workspace packages-i18n",
"COLLABORATION_SERVER": "yarn workspace server-y-provider",
"MCP_SERVER": "yarn workspace server-mcp",
"app:dev": "yarn APP_IMPRESS run dev",
"app:start": "yarn APP_IMPRESS run start",
"app:build": "yarn APP_IMPRESS run build",
"app:test": "yarn APP_IMPRESS run test",
"ci:build": "yarn APP_IMPRESS run build:ci",
"build": "yarn APP_IMPRESS run build && yarn COLLABORATION_SERVER run build",
"build": "yarn APP_IMPRESS run build && yarn COLLABORATION_SERVER run build && yarn MCP_SERVER run build",
"e2e:test": "yarn APP_E2E run test",
"lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-plugin-docs run lint && yarn I18N run lint && yarn COLLABORATION_SERVER run lint",
"lint": "yarn APP_IMPRESS run lint && yarn APP_E2E run lint && yarn workspace eslint-plugin-docs run lint && yarn I18N run lint && yarn COLLABORATION_SERVER run lint && yarn MCP_SERVER run lint",
"i18n:extract": "yarn I18N run extract-translation",
"i18n:deploy": "yarn I18N run format-deploy && yarn APP_IMPRESS prettier",
"i18n:test": "yarn I18N run test",
"test": "yarn server:test && yarn app:test",
"server:test": "yarn COLLABORATION_SERVER run test"
"server:test": "yarn COLLABORATION_SERVER run test && yarn MCP_SERVER run test"
},
"resolutions": {
"@tiptap/core": "3.30.6",
+66
View File
@@ -0,0 +1,66 @@
FROM node:22-alpine AS base
# Upgrade system packages to install security updates
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
FROM base AS mcp-deps
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/mcp/package.json ./servers/mcp/package.json
COPY ./src/frontend/packages/eslint-plugin-docs/package.json ./packages/eslint-plugin-docs/package.json
RUN yarn install
COPY ./src/frontend/packages/eslint-plugin-docs ./packages/eslint-plugin-docs
COPY ./src/frontend/servers/mcp ./servers/mcp
FROM mcp-deps AS mcp-development
WORKDIR /home/frontend/servers/mcp
EXPOSE 4455
CMD [ "yarn", "dev" ]
FROM mcp-deps AS mcp-builder
WORKDIR /home/frontend/servers/mcp
RUN yarn build
FROM base AS mcp
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/yarn.lock ./yarn.lock
COPY ./src/frontend/servers/mcp/package.json ./servers/mcp/package.json
WORKDIR /home/frontend/servers/mcp
COPY --from=mcp-builder \
/home/frontend/servers/mcp/dist \
./dist
RUN NODE_ENV=production yarn install --frozen-lockfile
# Remove npm, contains CVE related to cross-spawn and we don't use it.
RUN rm -rf /usr/local/bin/npm /usr/local/lib/node_modules/npm
ENV NODE_OPTIONS="--max-old-space-size=2048"
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
# Copy entrypoint
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
EXPOSE 4455
CMD ["yarn", "start"]
@@ -0,0 +1,193 @@
/* eslint-disable jest/unbound-method */
/**
* A handful of focused tests, not an exhaustive suite: unauthenticated / wrong-audience /
* missing-scope / bad-input rejections, and one happy path
* proving a valid call reaches Django with the caller's token forwarded unchanged. Django HTTP
* calls are mocked; JWT verification is mocked to avoid depending on a live JWKS.
*
* `expect(mockedAxios.post)`-style assertions below trip the unbound-method rule even though
* vitest's mocked functions don't rely on `this`; disabled for this file rather than annotated
* line by line.
*/
import { type Server, createServer } from 'node:http';
import { AddressInfo } from 'node:net';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import axios from 'axios';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
test,
vi,
} from 'vitest';
vi.mock('@/env.js', () => ({
MCP_HOST: '127.0.0.1',
MCP_PORT: 0,
MCP_RESOURCE_URL: 'http://localhost:4455/mcp',
KEYCLOAK_ISSUER: 'http://localhost/realms/impress',
KEYCLOAK_JWKS_URL:
'http://localhost/realms/impress/protocol/openid-connect/certs',
KEYCLOAK_DISCOVERY_URL:
'http://localhost/realms/impress/.well-known/openid-configuration',
DOCS_API_URL: 'http://docs-api.test',
MCP_AUDIENCE: 'docs-mcp',
}));
vi.mock('axios');
const mockedAxios = vi.mocked(axios, true);
type FakeTokenClaims = {
sub: string;
aud: string;
scope: string;
exp: number;
};
let verifiedClaims: FakeTokenClaims | null = null;
vi.mock('jose', () => ({
createRemoteJWKSet: vi.fn(() => ({})),
jwtVerify: vi.fn(async (token: string) => {
if (!verifiedClaims || token !== 'valid-token') {
throw new Error('invalid token');
}
return { payload: verifiedClaims };
}),
}));
import { createApp } from '@/server.js';
async function startServer(): Promise<{ server: Server; url: string }> {
const app = await createApp();
const server = createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return { server, url: `http://127.0.0.1:${port}/mcp` };
}
async function connectClient(url: string, token?: string) {
const transport = new StreamableHTTPClientTransport(new URL(url), {
requestInit: token
? { headers: { Authorization: `Bearer ${token}` } }
: undefined,
});
const client = new Client({ name: 'test-client', version: '0.0.1' });
await client.connect(transport);
return client;
}
describe('docs-mcp server', () => {
let server: Server;
let url: string;
beforeAll(async () => {
mockedAxios.get.mockResolvedValueOnce({
data: {
issuer: 'http://localhost/realms/impress',
authorization_endpoint:
'http://localhost/realms/impress/protocol/openid-connect/auth',
token_endpoint:
'http://localhost/realms/impress/protocol/openid-connect/token',
response_types_supported: ['code'],
},
});
({ server, url } = await startServer());
});
afterAll(() => {
server.close();
});
afterEach(() => {
verifiedClaims = null;
mockedAxios.post.mockReset();
mockedAxios.request.mockReset();
});
test('a request without a token is rejected with 401', async () => {
await expect(connectClient(url)).rejects.toThrow();
});
test('a token with the wrong audience is rejected', async () => {
verifiedClaims = null; // jwtVerify mock throws for anything but 'valid-token' with claims set
await expect(connectClient(url, 'valid-token')).rejects.toThrow();
});
test('a tool call without its required scope is rejected', async () => {
verifiedClaims = {
sub: 'user-1',
aud: 'docs-mcp',
scope: 'docs:documents:read', // missing docs:documents:search
exp: Math.floor(Date.now() / 1000) + 3600,
};
const client = await connectClient(url, 'valid-token');
const result = await client.callTool({
name: 'search_documents',
arguments: { query: 'hello' },
});
expect(result.isError).toBe(true);
expect(mockedAxios.post).not.toHaveBeenCalled();
});
test('invalid tool input is rejected', async () => {
verifiedClaims = {
sub: 'user-1',
aud: 'docs-mcp',
scope: 'docs:documents:search',
exp: Math.floor(Date.now() / 1000) + 3600,
};
const client = await connectClient(url, 'valid-token');
const result = await client.callTool({
name: 'search_documents',
arguments: { query: '' }, // empty query violates min(1)
});
expect(result.isError).toBe(true);
expect(mockedAxios.post).not.toHaveBeenCalled();
});
test('a valid tool call forwards the caller token to the expected Django endpoint', async () => {
verifiedClaims = {
sub: 'user-1',
aud: 'docs-mcp',
scope: 'docs:documents:search',
exp: Math.floor(Date.now() / 1000) + 3600,
};
mockedAxios.request.mockResolvedValueOnce({
data: [
{
id: 'doc-1',
title: 'Hello',
excerpt: 'World',
updated_at: '2026-01-01T00:00:00Z',
},
],
});
const client = await connectClient(url, 'valid-token');
const result = await client.callTool({
name: 'search_documents',
arguments: { query: 'hello' },
});
expect(result.isError).toBeFalsy();
expect(mockedAxios.post).not.toHaveBeenCalled();
expect(mockedAxios.request).toHaveBeenCalledWith(
expect.objectContaining({
method: 'POST',
url: 'http://docs-api.test/api/v1.0/mcp/documents/search',
headers: { Authorization: 'Bearer valid-token' },
}),
);
});
});
@@ -0,0 +1,20 @@
import { defineConfig } from '@eslint/config-helpers';
import docsPlugin from 'eslint-plugin-docs';
const eslintConfig = defineConfig([
{
ignores: ['dist/**'],
},
{
files: ['**/*.mjs', '**/*.ts'],
plugins: {
docs: docsPlugin,
},
extends: ['docs/next'],
rules: {
'@next/next/no-html-link-for-pages': 'off',
},
},
]);
export default eslintConfig;
+5
View File
@@ -0,0 +1,5 @@
{
"watch": ["src"],
"ext": "ts",
"exec": "yarn build && yarn start"
}
+39
View File
@@ -0,0 +1,39 @@
{
"name": "server-mcp",
"version": "5.4.1",
"description": "Remote MCP server for Docs (docs-mcp)",
"repository": "https://github.com/suitenumerique/docs",
"license": "MIT",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
"dev": "nodemon --config nodemon.json",
"start": "node ./dist/start-server.js",
"lint": "eslint",
"test": "vitest run"
},
"engines": {
"node": ">=22"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"axios": "1.18.1",
"express": "5.2.1",
"jose": "6.2.5",
"zod": "4.4.3"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "*",
"cross-env": "10.1.0",
"eslint-plugin-docs": "*",
"nodemon": "3.1.14",
"supertest": "7.2.2",
"@types/supertest": "7.2.1",
"ts-node": "10.9.2",
"tsc-alias": "1.9.1",
"typescript": "*",
"vitest": "4.1.10"
},
"packageManager": "yarn@1.22.22"
}
@@ -0,0 +1,31 @@
/**
* Request-scoped authentication context passed to tool handlers.
*
* Deliberately narrow: handlers get the authenticated user's subject and granted scopes to
* decide what they're allowed to do, and the raw access token they hand to `docsApiClient`,
* which forwards it as-is to Django. Handlers never parse or decode the token themselves —
* decoding already happened once, in `jwtVerifier.ts`.
*/
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
export type McpAuthContext = {
subject: string;
scopes: ReadonlySet<string>;
accessToken: string;
};
export function toMcpAuthContext(
authInfo: AuthInfo | undefined,
): McpAuthContext {
const subject = authInfo?.extra?.sub;
if (!authInfo || typeof subject !== 'string') {
throw new Error('Missing or invalid auth info on request');
}
return {
subject,
scopes: new Set(authInfo.scopes),
accessToken: authInfo.token,
};
}
@@ -0,0 +1,59 @@
/**
* Validates Keycloak-issued MCP access tokens.
*
* This implements the MCP SDK's `OAuthTokenVerifier` interface: given a raw bearer token, it
* must either return `AuthInfo` (signature, issuer, expiry, audience and subject all valid) or
* throw, in which case the SDK's `requireBearerAuth` middleware turns that into an HTTP 401.
*
* We validate against Keycloak's realm JWKS (fetched via OIDC discovery) rather than trusting
* an unsigned token, and we require the `docs-mcp` audience explicitly: Keycloak only adds it
* when the `docs-mcp` optional client scope was requested (see docker/auth/realm.json), so its
* presence proves the client asked for an MCP-scoped token, not just any Keycloak token.
*
* Any error we don't recognize as InvalidTokenError gets swallowed by the SDK's bearerAuth
* middleware into a generic, unlogged 500 — so we log (never the token itself) before
* re-throwing as InvalidTokenError, to keep the real reason visible in this server's logs.
*/
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
import type { OAuthTokenVerifier } from '@modelcontextprotocol/sdk/server/auth/provider.js';
import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
import { type JWTPayload, createRemoteJWKSet, jwtVerify } from 'jose';
import { KEYCLOAK_ISSUER, KEYCLOAK_JWKS_URL, MCP_AUDIENCE } from '@/env.js';
const jwks = createRemoteJWKSet(new URL(KEYCLOAK_JWKS_URL));
export class KeycloakJwtVerifier implements OAuthTokenVerifier {
async verifyAccessToken(token: string): Promise<AuthInfo> {
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(token, jwks, {
issuer: KEYCLOAK_ISSUER,
audience: MCP_AUDIENCE,
}));
} catch (error) {
console.error('JWT verification failed:', error);
throw new InvalidTokenError(
error instanceof Error ? error.message : 'Token verification failed',
);
}
if (typeof payload.sub !== 'string') {
throw new InvalidTokenError('Token has no subject (sub) claim');
}
const scope = typeof payload.scope === 'string' ? payload.scope : '';
return {
token,
clientId:
(typeof payload.azp === 'string' && payload.azp) ||
(typeof payload.client_id === 'string' && payload.client_id) ||
'unknown',
scopes: scope.split(' ').filter(Boolean),
expiresAt: typeof payload.exp === 'number' ? payload.exp : undefined,
extra: { sub: payload.sub },
};
}
}
@@ -0,0 +1,115 @@
/**
* Thin client for the Docs API's MCP-facing endpoints (`core/mcp_api` on the Django side).
*
* The MCP server is a plain OAuth resource server: it validates the caller's Keycloak access
* token (see `auth/jwtVerifier.ts`) and forwards that same token, unchanged, to Django. Django
* introspects it (django-lasuite's `ResourceServerAuthentication`), checks the token's origin
* client is allow-listed (`OIDC_RS_ALLOWED_AUDIENCES`), resolves the user by `sub`, and its
* `DocumentViewSet` permissions/querysets decide what that user can actually see or create.
* This is the same mechanism the (separate) `external_api` feature uses — see
* `documentation/resource_server.md`.
*/
import axios from 'axios';
import { DOCS_API_URL } from '@/env.js';
import type { McpAuthContext } from './auth/context.js';
export type DocumentSummary = {
id: string;
title: string | null;
excerpt: string | null;
updated_at: string;
children_ids: string[];
};
export type DocumentContent = {
id: string;
title: string | null;
content: string;
truncated: boolean;
updated_at: string;
};
export type DocumentCreated = {
id: string;
title: string | null;
created_at: string;
};
/** Thrown with a message that is safe to surface directly to an MCP tool caller. */
export class DocsApiError extends Error {}
function detailFromResponse(data: unknown): string | undefined {
if (data && typeof data === 'object' && 'detail' in data) {
const { detail } = data;
return typeof detail === 'string' ? detail : undefined;
}
return undefined;
}
async function docsApiRequest<T>(
auth: McpAuthContext,
config: { method: 'GET' | 'POST'; path: string; data?: unknown },
): Promise<T> {
try {
const response = await axios.request<T>({
method: config.method,
url: `${DOCS_API_URL}${config.path}`,
data: config.data,
headers: { Authorization: `Bearer ${auth.accessToken}` },
timeout: 10000,
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response) {
throw new DocsApiError(
detailFromResponse(error.response.data) ??
`Docs API request failed with status ${error.response.status}.`,
);
}
throw new DocsApiError('Docs API is unreachable.');
}
throw error;
}
}
export function searchDocuments(
auth: McpAuthContext,
query: string,
limit: number,
): Promise<DocumentSummary[]> {
return docsApiRequest(auth, {
method: 'POST',
path: '/api/v1.0/mcp/documents/search',
data: { query, limit },
});
}
export function readDocument(
auth: McpAuthContext,
documentId: string,
): Promise<DocumentContent> {
return docsApiRequest(auth, {
method: 'GET',
path: `/api/v1.0/mcp/documents/${documentId}`,
});
}
export function createDocument(
auth: McpAuthContext,
input: { title: string; content: string; parentId?: string },
): Promise<DocumentCreated> {
return docsApiRequest(auth, {
method: 'POST',
path: '/api/v1.0/mcp/documents',
data: {
title: input.title,
content: input.content,
parent_id: input.parentId,
},
});
}
+34
View File
@@ -0,0 +1,34 @@
/** Environment configuration for docs-mcp, validated at startup (fail fast). */
function required(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const MCP_HOST = process.env.MCP_HOST || '0.0.0.0';
export const MCP_PORT = Number(process.env.MCP_PORT || 4455);
/** Canonical URL of this MCP server's /mcp endpoint, used as the OAuth "resource". */
export const MCP_RESOURCE_URL = required('MCP_RESOURCE_URL');
/**
* Keycloak realm issuer as seen by end users/clients, e.g. http://localhost:8083/realms/impress.
* Used only to validate the `iss` claim and to advertise metadata; never called over the
* network directly (see KEYCLOAK_*_URL below for the network-reachable endpoints), since in
* Docker this server reaches Keycloak through an internal alias, not through localhost.
*/
export const KEYCLOAK_ISSUER = required('KEYCLOAK_ISSUER');
export const KEYCLOAK_JWKS_URL = required('KEYCLOAK_JWKS_URL');
export const KEYCLOAK_DISCOVERY_URL = required('KEYCLOAK_DISCOVERY_URL');
export const DOCS_API_URL = required('DOCS_API_URL');
/**
* Audience this server itself must find on incoming MCP access tokens. Keycloak only adds it
* when the `docs-mcp` optional client scope was requested (see docker/auth/realm.json), so its
* presence proves the client asked for an MCP-scoped token, not just any Keycloak token.
*/
export const MCP_AUDIENCE = process.env.MCP_AUDIENCE || 'docs-mcp';
@@ -0,0 +1,42 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
import createDocument from './tools/createDocument.js';
import readDocument from './tools/readDocument.js';
import searchDocuments from './tools/searchDocuments.js';
import type { ToolExtra, ToolModule } from './tools/types.js';
const tools: ToolModule[] = [searchDocuments, readDocument, createDocument];
/** Reject a tool call whose token doesn't carry the scope that tool requires. */
function withScopeCheck(
tool: ToolModule,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): (args: any, extra: ToolExtra) => Promise<CallToolResult> {
return async (args, extra) => {
if (!extra.authInfo?.scopes.includes(tool.requiredScope)) {
return {
isError: true,
content: [
{
type: 'text',
text: `Missing required scope "${tool.requiredScope}" for tool "${tool.name}".`,
},
],
};
}
return tool.handler(args, extra);
};
}
/** Builds a fresh `docs-mcp` server instance (stateless: one per request). */
export function buildServer(): McpServer {
const server = new McpServer({ name: 'docs-mcp', version: '0.1.0' });
for (const tool of tools) {
server.registerTool(tool.name, tool.config, withScopeCheck(tool));
}
return server;
}
@@ -0,0 +1,45 @@
import { z } from 'zod';
import { toMcpAuthContext } from '@/auth/context.js';
import { createDocument as createDocumentRequest } from '@/docsApiClient.js';
import type { ToolExtra, ToolModule } from './types.js';
const REQUIRED_SCOPE = 'docs:documents:create';
export const inputSchema = {
title: z.string().min(1).max(255),
content: z.string().max(50000),
parentId: z.string().uuid().optional(),
};
type Input = z.infer<z.ZodObject<typeof inputSchema>>;
const createDocument: ToolModule = {
name: 'create_document',
config: {
title: 'Create document',
description: 'Create a document for the authenticated user.',
inputSchema,
},
requiredScope: REQUIRED_SCOPE,
async handler({ title, content, parentId }: Input, extra: ToolExtra) {
const auth = toMcpAuthContext(extra.authInfo);
const document = await createDocumentRequest(auth, {
title,
content,
parentId,
});
return {
content: [
{
type: 'text',
text: `Created document "${document.title}" (id: ${document.id}, created_at: ${document.created_at}).`,
},
],
};
},
};
export default createDocument;
@@ -0,0 +1,41 @@
import { z } from 'zod';
import { toMcpAuthContext } from '@/auth/context.js';
import { readDocument as readDocumentApi } from '@/docsApiClient.js';
import type { ToolExtra, ToolModule } from './types.js';
const REQUIRED_SCOPE = 'docs:documents:read';
export const inputSchema = {
documentId: z.string().uuid(),
};
type Input = z.infer<z.ZodObject<typeof inputSchema>>;
const readDocument: ToolModule = {
name: 'read_document',
config: {
title: 'Read document',
description: 'Read a document accessible to the authenticated user.',
inputSchema,
},
requiredScope: REQUIRED_SCOPE,
async handler({ documentId }: Input, extra: ToolExtra) {
const auth = toMcpAuthContext(extra.authInfo);
const document = await readDocumentApi(auth, documentId);
return {
content: [
{
type: 'text',
text: `# ${document.title ?? 'Untitled'}\n\n${document.content}${
document.truncated ? '\n\n[content truncated]' : ''
}`,
},
],
};
},
};
export default readDocument;
@@ -0,0 +1,50 @@
import { z } from 'zod';
import { toMcpAuthContext } from '@/auth/context.js';
import { searchDocuments as searchDocumentsApi } from '@/docsApiClient.js';
import type { ToolExtra, ToolModule } from './types.js';
const REQUIRED_SCOPE = 'docs:documents:search';
export const inputSchema = {
query: z.string().min(1).max(512),
limit: z.number().int().min(1).max(20).default(5),
};
type Input = z.infer<z.ZodObject<typeof inputSchema>>;
const searchDocuments: ToolModule = {
name: 'search_documents',
config: {
title: 'Search documents',
description: 'Search the documents accessible to the authenticated user.',
inputSchema,
},
requiredScope: REQUIRED_SCOPE,
async handler({ query, limit }: Input, extra: ToolExtra) {
const auth = toMcpAuthContext(extra.authInfo);
const results = await searchDocumentsApi(auth, query, limit);
return {
content: [
{
type: 'text',
text: JSON.stringify(
results.map((doc) => ({
id: doc.id,
title: doc.title,
excerpt: doc.excerpt,
updated_at: doc.updated_at,
children_ids: doc.children_ids,
})),
null,
2,
),
},
],
};
},
};
export default searchDocuments;
@@ -0,0 +1,23 @@
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import type {
CallToolResult,
ServerNotification,
ServerRequest,
} from '@modelcontextprotocol/sdk/types.js';
import type { ZodRawShape } from 'zod';
export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
export type ToolModule = {
name: string;
config: {
title: string;
description: string;
inputSchema: ZodRawShape;
};
/** OAuth scope required to call this tool (checked before it ever reaches Django). */
requiredScope: string;
// Args are validated against `config.inputSchema` by the MCP SDK before this runs.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
handler: (args: any, extra: ToolExtra) => Promise<CallToolResult>;
};
+36
View File
@@ -0,0 +1,36 @@
/**
* OAuth Protected Resource Metadata (RFC 9728).
*
* This is how an MCP client discovers *which* authorization server to use and *which* scopes
* to request before it ever talks to Keycloak: it fetches
* `/.well-known/oauth-protected-resource` from this server, learns the issuer and supported
* scopes, then drives the Authorization Code + PKCE flow against Keycloak itself.
*
* We reuse Keycloak's own OIDC discovery document as the `oauthMetadata` the SDK's helper
* expects, rather than hand-rolling one.
*/
import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth.js';
import axios from 'axios';
import type { Router } from 'express';
import { KEYCLOAK_DISCOVERY_URL, MCP_RESOURCE_URL } from '@/env.js';
export async function buildProtectedResourceMetadataRouter(): Promise<Router> {
const { data: oauthMetadata } = await axios.get<OAuthMetadata>(
KEYCLOAK_DISCOVERY_URL,
);
return mcpAuthMetadataRouter({
oauthMetadata,
resourceServerUrl: new URL(MCP_RESOURCE_URL),
resourceName: 'docs-mcp',
scopesSupported: [
'docs:documents:search',
'docs:documents:read',
'docs:documents:create',
'docs-mcp',
],
});
}
+47
View File
@@ -0,0 +1,47 @@
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
import { getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import express from 'express';
import { KeycloakJwtVerifier } from '@/auth/jwtVerifier.js';
import { MCP_RESOURCE_URL } from '@/env.js';
import { buildServer } from '@/mcp/buildServer.js';
import { buildProtectedResourceMetadataRouter } from '@/prm.js';
export async function createApp(): Promise<express.Express> {
const app = express();
app.use(express.json());
app.get('/healthz', (_req, res) => {
res.status(200).json({ status: 'ok' });
});
app.use(await buildProtectedResourceMetadataRouter());
const verifier = new KeycloakJwtVerifier();
const mcpAuth = requireBearerAuth({
verifier,
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(
new URL(MCP_RESOURCE_URL),
),
});
// Stateless mode: a fresh McpServer + transport per request, so concurrent requests from
// different clients never share JSON-RPC request IDs or in-memory session state.
app.all('/mcp', mcpAuth, async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on('close', () => {
void transport.close();
void server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
});
return app;
}
@@ -0,0 +1,13 @@
import { MCP_HOST, MCP_PORT } from '@/env.js';
import { createApp } from '@/server.js';
createApp()
.then((app) => {
app.listen(MCP_PORT, MCP_HOST, () => {
console.log(`docs-mcp listening on http://${MCP_HOST}:${MCP_PORT}`);
});
})
.catch((error: unknown) => {
console.error('Failed to start docs-mcp:', error);
process.exit(1);
});
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src"
},
"include": ["**/*.ts"],
"exclude": ["node_modules", "dist", "__tests__"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es2022",
"lib": ["esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"incremental": false,
"outDir": "./dist",
"paths": {
"@/*": ["./src/*"]
}
},
"tsc-alias": {
"resolveFullPaths": true,
"verbose": false
},
"include": ["**/*.ts"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,11 @@
import { URL, fileURLToPath } from 'url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});
+209 -5
View File
@@ -2268,6 +2268,11 @@
lib0 "^0.2.47"
ws "^8.5.0"
"@hono/node-server@^1.19.9 || ^2.0.5":
version "2.0.12"
resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-2.0.12.tgz#23876c8640ec7b9cc77dd457758464b1206a11cd"
integrity sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==
"@humanfs/core@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60"
@@ -2933,6 +2938,29 @@
dependencies:
"@chevrotain/types" "~11.1.2"
"@modelcontextprotocol/sdk@1.30.0":
version "1.30.0"
resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz#dfa8a48347ec2d2c0d47917d7dc57f754e37f5ff"
integrity sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==
dependencies:
"@hono/node-server" "^1.19.9 || ^2.0.5"
ajv "^8.17.1"
ajv-formats "^3.0.1"
content-type "^1.0.5"
cors "^2.8.5"
cross-spawn "^7.0.5"
eventsource "^3.0.2"
eventsource-parser "^3.0.0"
express "^5.2.1"
express-rate-limit "^8.2.1"
hono "^4.11.4"
jose "^6.1.3"
json-schema-typed "^8.0.2"
pkce-challenge "^5.0.0"
raw-body "^3.0.0"
zod "^3.25 || ^4.0"
zod-to-json-schema "^3.25.1"
"@napi-rs/canvas-android-arm64@0.1.100":
version "0.1.100"
resolved "https://registry.yarnpkg.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz#b7c68b91d57702a5fc523fa82de72ea741e6766e"
@@ -6951,6 +6979,18 @@
"@typescript-eslint/scope-manager" "^8.58.0"
"@typescript-eslint/utils" "^8.58.0"
"@vitest/expect@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.10.tgz#799c06fc44bb0cf7e2784137b627c5cc173285d4"
integrity sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==
dependencies:
"@standard-schema/spec" "^1.1.0"
"@types/chai" "^5.2.2"
"@vitest/spy" "4.1.10"
"@vitest/utils" "4.1.10"
chai "^6.2.2"
tinyrainbow "^3.1.0"
"@vitest/expect@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.1.11.tgz#5f580d1f9cdbba314dbf23b2d911f8eb23878f5f"
@@ -6963,6 +7003,15 @@
chai "^6.2.2"
tinyrainbow "^3.1.0"
"@vitest/mocker@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.10.tgz#2413987ab4cd7fa1c2b614b404c407bf6ad1ead1"
integrity sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==
dependencies:
"@vitest/spy" "4.1.10"
estree-walker "^3.0.3"
magic-string "^0.30.21"
"@vitest/mocker@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.1.11.tgz#8e2906361bc5dfa271757a858ae80643118fcbb4"
@@ -6972,6 +7021,13 @@
estree-walker "^3.0.3"
magic-string "^0.30.21"
"@vitest/pretty-format@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.10.tgz#75542e7273a08cc10fd4d8dad4e3eb1f16cd958c"
integrity sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==
dependencies:
tinyrainbow "^3.1.0"
"@vitest/pretty-format@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.1.11.tgz#8b28eb8240771d6ea970e33beaeb41384b51868e"
@@ -6979,6 +7035,14 @@
dependencies:
tinyrainbow "^3.1.0"
"@vitest/runner@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.10.tgz#febf0a21a9168421422d1955370e606feab60355"
integrity sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==
dependencies:
"@vitest/utils" "4.1.10"
pathe "^2.0.3"
"@vitest/runner@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.1.11.tgz#bfbad98c8d6c3f1fb4df12056ad569821ff77f21"
@@ -6987,6 +7051,16 @@
"@vitest/utils" "4.1.11"
pathe "^2.0.3"
"@vitest/snapshot@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.10.tgz#7e3e9fec7d4d47232e493cfdcbd2170de4371c04"
integrity sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==
dependencies:
"@vitest/pretty-format" "4.1.10"
"@vitest/utils" "4.1.10"
magic-string "^0.30.21"
pathe "^2.0.3"
"@vitest/snapshot@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.1.11.tgz#df461eb165924a3155986dde68e13360f53f3d4c"
@@ -6997,11 +7071,25 @@
magic-string "^0.30.21"
pathe "^2.0.3"
"@vitest/spy@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.10.tgz#5c0bfa97b56bba9e37403c976db776ff6ab56f65"
integrity sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==
"@vitest/spy@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.1.11.tgz#0add45cae953afed9c88f98e2f6fc9164558c32a"
integrity sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==
"@vitest/utils@4.1.10":
version "4.1.10"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.10.tgz#ffc71055f18bfccb1fd0586365ebc2824892e403"
integrity sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==
dependencies:
"@vitest/pretty-format" "4.1.10"
convert-source-map "^2.0.0"
tinyrainbow "^3.1.0"
"@vitest/utils@4.1.11":
version "4.1.11"
resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.1.11.tgz#9b27a4293b827942b223539bfab1bd9f7eada31b"
@@ -7226,6 +7314,13 @@ ajv-formats@^2.1.1:
dependencies:
ajv "^8.0.0"
ajv-formats@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578"
integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==
dependencies:
ajv "^8.0.0"
ajv-keywords@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16"
@@ -7253,6 +7348,16 @@ ajv@^8.0.0, ajv@^8.0.1, ajv@^8.6.0, ajv@^8.9.0:
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
ajv@^8.17.1:
version "8.20.0"
resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.20.0.tgz#304b3636add88ba7d936760dd50ece006dea95f9"
integrity sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==
dependencies:
fast-deep-equal "^3.1.3"
fast-uri "^3.0.1"
json-schema-traverse "^1.0.0"
require-from-string "^2.0.2"
ansi-escapes@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
@@ -7501,6 +7606,16 @@ axe-core@^4.10.0:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6"
integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==
axios@1.18.1:
version "1.18.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.18.1.tgz#d63f9863bcd8938815c86f9e2abd380189d96dfe"
integrity sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==
dependencies:
follow-redirects "^1.16.0"
form-data "^4.0.5"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0"
axios@1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.19.0.tgz#ddf864d4c8233c0e6873746ab59361537d05ad39"
@@ -8231,7 +8346,7 @@ core-util-is@~1.0.0:
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
cors@2.8.6:
cors@2.8.6, cors@^2.8.5:
version "2.8.6"
resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96"
integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==
@@ -8297,7 +8412,7 @@ cross-env@10.1.0:
"@epic-web/invariant" "^1.0.0"
cross-spawn "^7.0.6"
cross-spawn@^7.0.3, cross-spawn@^7.0.6:
cross-spawn@^7.0.3, cross-spawn@^7.0.5, cross-spawn@^7.0.6:
version "7.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
@@ -9649,6 +9764,11 @@ events@^3.2.0, events@^3.3.0:
resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
eventsource-parser@^3.0.0, eventsource-parser@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz#4e198eb91cd333d0a8ddcc036502b3618a25f449"
integrity sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==
eventsource-parser@^3.0.6:
version "3.0.6"
resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90"
@@ -9659,6 +9779,13 @@ eventsource-parser@^3.0.8:
resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.8.tgz#1c792503e4080455d00701bb1f7a1d60734d0e58"
integrity sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==
eventsource@^3.0.2:
version "3.0.7"
resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989"
integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==
dependencies:
eventsource-parser "^3.0.1"
execa@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
@@ -9713,6 +9840,14 @@ expect@^30.0.0:
jest-mock "30.2.0"
jest-util "30.2.0"
express-rate-limit@^8.2.1:
version "8.6.1"
resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.6.1.tgz#e0977ec8075346e207d788383362ada7c08324ae"
integrity sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==
dependencies:
debug "^4.4.3"
ip-address "^10.2.0"
express-ws@5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/express-ws/-/express-ws-5.0.2.tgz#5b02d41b937d05199c6c266d7cc931c823bda8eb"
@@ -9720,7 +9855,7 @@ express-ws@5.0.2:
dependencies:
ws "^7.4.6"
express@5.2.1:
express@5.2.1, express@^5.2.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04"
integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==
@@ -10496,6 +10631,11 @@ hoist-non-react-statics@^3.3.1, hoist-non-react-statics@^3.3.2:
dependencies:
react-is "^16.7.0"
hono@^4.11.4:
version "4.12.32"
resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.32.tgz#9489eb5507c2b90554ee7817a3f97d9b754ee1ff"
integrity sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==
hookified@^1.14.0, hookified@^1.15.0:
version "1.15.1"
resolved "https://registry.yarnpkg.com/hookified/-/hookified-1.15.1.tgz#b1fafeaa5489cdc29cb85546a8f837ed4ffbbcb6"
@@ -10781,6 +10921,11 @@ intl-messageformat@^10.1.0:
"@formatjs/icu-messageformat-parser" "2.11.4"
tslib "^2.8.0"
ip-address@^10.2.0:
version "10.3.1"
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.3.1.tgz#929f9629d1724f7e1b7485ce89752f3675336a10"
integrity sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==
ipaddr.js@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -11585,6 +11730,11 @@ jest@30.4.2:
import-local "^3.2.0"
jest-cli "30.4.2"
jose@6.2.5, jose@^6.1.3:
version "6.2.5"
resolved "https://registry.yarnpkg.com/jose/-/jose-6.2.5.tgz#2db56ed2aa89786cd825843416136de10d6ad424"
integrity sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -11684,6 +11834,11 @@ json-schema-traverse@^1.0.0:
resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
json-schema-typed@^8.0.2:
version "8.0.2"
resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz#e98ee7b1899ff4a184534d1f167c288c66bbeff4"
integrity sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==
json-schema@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5"
@@ -12937,6 +13092,11 @@ pixelmatch@7.2.0:
dependencies:
pngjs "^7.0.0"
pkce-challenge@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d"
integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==
pkg-dir@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
@@ -13360,7 +13520,7 @@ range-parser@^1.2.1:
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
raw-body@^3.0.2:
raw-body@^3.0.0, raw-body@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51"
integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==
@@ -15327,6 +15487,19 @@ ts-node@10.9.2:
v8-compile-cache-lib "^3.0.1"
yn "3.1.1"
tsc-alias@1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.9.1.tgz#071456501f1ef87e61d1e6afe34e051dbf8ce01f"
integrity sha512-sFZdVFthH8uvdplPJrOYGOHcxu6UPtcAcY678JPwEQiMzgLZYFO7Qc/rzELp7ingTc+OxtzH6n+8Pn2eVQep6w==
dependencies:
chokidar "^3.5.3"
commander "^9.0.0"
get-tsconfig "^4.10.0"
globby "^11.0.4"
mylas "^2.1.9"
normalize-path "^3.0.0"
plimit-lit "^1.2.6"
tsc-alias@1.9.2:
version "1.9.2"
resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.9.2.tgz#953563399e28f80b6e8ce2efb030221c3148f7cb"
@@ -15831,6 +16004,32 @@ vitest-mock-extended@5.1.1:
dependencies:
ts-essentials "^10.2.1"
vitest@4.1.10:
version "4.1.10"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.10.tgz#7e9285efe264b1167050b7a3a7ff34788e1b7afc"
integrity sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==
dependencies:
"@vitest/expect" "4.1.10"
"@vitest/mocker" "4.1.10"
"@vitest/pretty-format" "4.1.10"
"@vitest/runner" "4.1.10"
"@vitest/snapshot" "4.1.10"
"@vitest/spy" "4.1.10"
"@vitest/utils" "4.1.10"
es-module-lexer "^2.0.0"
expect-type "^1.3.0"
magic-string "^0.30.21"
obug "^2.1.1"
pathe "^2.0.3"
picomatch "^4.0.3"
std-env "^4.0.0-rc.1"
tinybench "^2.9.0"
tinyexec "^1.0.2"
tinyglobby "^0.2.15"
tinyrainbow "^3.1.0"
vite "^6.0.0 || ^7.0.0 || ^8.0.0"
why-is-node-running "^2.3.0"
vitest@4.1.11:
version "4.1.11"
resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.1.11.tgz#1653c1521ae917f960d9b21877797c47dfd8bf21"
@@ -16433,12 +16632,17 @@ yoga-layout@^3.2.1:
resolved "https://registry.yarnpkg.com/yoga-layout/-/yoga-layout-3.2.1.tgz#d2d1ba06f0e81c2eb650c3e5ad8b0b4adde1e843"
integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==
zod-to-json-schema@^3.25.1:
version "3.25.2"
resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz#3fa799a7badd554541472fb65843fdc460b2e5aa"
integrity sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==
"zod-validation-error@^3.5.0 || ^4.0.0":
version "4.0.2"
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918"
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
zod@4.4.3:
zod@4.4.3, "zod@^3.25 || ^4.0":
version "4.4.3"
resolved "https://registry.yarnpkg.com/zod/-/zod-4.4.3.tgz#b680f172885d18bbebf21a834ea25e55a1bbf356"
integrity sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==