From f9e30f1ccc5ddfd4c93ae0bc34be1607b032ef21 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 31 Jul 2026 22:46:27 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(mcp)=20add=20the=20docs-mcp=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 1 + Makefile | 8 + compose.yml | 23 ++ documentation/mcp_server.md | 221 ++++++++++++++++++ env.d/development/mcp | 19 ++ src/frontend/package.json | 7 +- src/frontend/servers/mcp/Dockerfile | 66 ++++++ .../servers/mcp/__tests__/server.test.ts | 193 +++++++++++++++ src/frontend/servers/mcp/eslint.config.mjs | 20 ++ src/frontend/servers/mcp/nodemon.json | 5 + src/frontend/servers/mcp/package.json | 39 ++++ src/frontend/servers/mcp/src/auth/context.ts | 31 +++ .../servers/mcp/src/auth/jwtVerifier.ts | 59 +++++ src/frontend/servers/mcp/src/docsApiClient.ts | 115 +++++++++ src/frontend/servers/mcp/src/env.ts | 34 +++ .../servers/mcp/src/mcp/buildServer.ts | 42 ++++ .../mcp/src/mcp/tools/createDocument.ts | 45 ++++ .../servers/mcp/src/mcp/tools/readDocument.ts | 41 ++++ .../mcp/src/mcp/tools/searchDocuments.ts | 50 ++++ .../servers/mcp/src/mcp/tools/types.ts | 23 ++ src/frontend/servers/mcp/src/prm.ts | 36 +++ src/frontend/servers/mcp/src/server.ts | 47 ++++ src/frontend/servers/mcp/src/start-server.ts | 13 ++ src/frontend/servers/mcp/tsconfig.build.json | 8 + src/frontend/servers/mcp/tsconfig.json | 25 ++ src/frontend/servers/mcp/vitest.config.mts | 11 + src/frontend/yarn.lock | 214 ++++++++++++++++- 27 files changed, 1388 insertions(+), 8 deletions(-) create mode 100644 documentation/mcp_server.md create mode 100644 env.d/development/mcp create mode 100644 src/frontend/servers/mcp/Dockerfile create mode 100644 src/frontend/servers/mcp/__tests__/server.test.ts create mode 100644 src/frontend/servers/mcp/eslint.config.mjs create mode 100644 src/frontend/servers/mcp/nodemon.json create mode 100644 src/frontend/servers/mcp/package.json create mode 100644 src/frontend/servers/mcp/src/auth/context.ts create mode 100644 src/frontend/servers/mcp/src/auth/jwtVerifier.ts create mode 100644 src/frontend/servers/mcp/src/docsApiClient.ts create mode 100644 src/frontend/servers/mcp/src/env.ts create mode 100644 src/frontend/servers/mcp/src/mcp/buildServer.ts create mode 100644 src/frontend/servers/mcp/src/mcp/tools/createDocument.ts create mode 100644 src/frontend/servers/mcp/src/mcp/tools/readDocument.ts create mode 100644 src/frontend/servers/mcp/src/mcp/tools/searchDocuments.ts create mode 100644 src/frontend/servers/mcp/src/mcp/tools/types.ts create mode 100644 src/frontend/servers/mcp/src/prm.ts create mode 100644 src/frontend/servers/mcp/src/server.ts create mode 100644 src/frontend/servers/mcp/src/start-server.ts create mode 100644 src/frontend/servers/mcp/tsconfig.build.json create mode 100644 src/frontend/servers/mcp/tsconfig.json create mode 100644 src/frontend/servers/mcp/vitest.config.mts diff --git a/CHANGELOG.md b/CHANGELOG.md index 48ade5220..eb812ea4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to ### Added - 🔧(backend) fine tune redis cache options +- ✨(mcp) add the docs-mcp server #2646 ### Changed diff --git a/Makefile b/Makefile index 54dd50b4d..c87bcfd46 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/compose.yml b/compose.yml index fe5732f51..77bdd6e9b 100644 --- a/compose.yml +++ b/compose.yml @@ -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: diff --git a/documentation/mcp_server.md b/documentation/mcp_server.md new file mode 100644 index 000000000..a7bc5b770 --- /dev/null +++ b/documentation/mcp_server.md @@ -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. diff --git a/env.d/development/mcp b/env.d/development/mcp new file mode 100644 index 000000000..da9a623bf --- /dev/null +++ b/env.d/development/mcp @@ -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 diff --git a/src/frontend/package.json b/src/frontend/package.json index b7ed921ec..2c6366481 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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", diff --git a/src/frontend/servers/mcp/Dockerfile b/src/frontend/servers/mcp/Dockerfile new file mode 100644 index 000000000..088ec614a --- /dev/null +++ b/src/frontend/servers/mcp/Dockerfile @@ -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"] diff --git a/src/frontend/servers/mcp/__tests__/server.test.ts b/src/frontend/servers/mcp/__tests__/server.test.ts new file mode 100644 index 000000000..d1861ffac --- /dev/null +++ b/src/frontend/servers/mcp/__tests__/server.test.ts @@ -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((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' }, + }), + ); + }); +}); diff --git a/src/frontend/servers/mcp/eslint.config.mjs b/src/frontend/servers/mcp/eslint.config.mjs new file mode 100644 index 000000000..06ff8fc28 --- /dev/null +++ b/src/frontend/servers/mcp/eslint.config.mjs @@ -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; diff --git a/src/frontend/servers/mcp/nodemon.json b/src/frontend/servers/mcp/nodemon.json new file mode 100644 index 000000000..0c61bfb3f --- /dev/null +++ b/src/frontend/servers/mcp/nodemon.json @@ -0,0 +1,5 @@ +{ + "watch": ["src"], + "ext": "ts", + "exec": "yarn build && yarn start" +} diff --git a/src/frontend/servers/mcp/package.json b/src/frontend/servers/mcp/package.json new file mode 100644 index 000000000..d06a789ea --- /dev/null +++ b/src/frontend/servers/mcp/package.json @@ -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" +} diff --git a/src/frontend/servers/mcp/src/auth/context.ts b/src/frontend/servers/mcp/src/auth/context.ts new file mode 100644 index 000000000..e27c345a1 --- /dev/null +++ b/src/frontend/servers/mcp/src/auth/context.ts @@ -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; + 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, + }; +} diff --git a/src/frontend/servers/mcp/src/auth/jwtVerifier.ts b/src/frontend/servers/mcp/src/auth/jwtVerifier.ts new file mode 100644 index 000000000..5a59a610d --- /dev/null +++ b/src/frontend/servers/mcp/src/auth/jwtVerifier.ts @@ -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 { + 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 }, + }; + } +} diff --git a/src/frontend/servers/mcp/src/docsApiClient.ts b/src/frontend/servers/mcp/src/docsApiClient.ts new file mode 100644 index 000000000..d20b2e1f6 --- /dev/null +++ b/src/frontend/servers/mcp/src/docsApiClient.ts @@ -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( + auth: McpAuthContext, + config: { method: 'GET' | 'POST'; path: string; data?: unknown }, +): Promise { + try { + const response = await axios.request({ + 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 { + return docsApiRequest(auth, { + method: 'POST', + path: '/api/v1.0/mcp/documents/search', + data: { query, limit }, + }); +} + +export function readDocument( + auth: McpAuthContext, + documentId: string, +): Promise { + 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 { + return docsApiRequest(auth, { + method: 'POST', + path: '/api/v1.0/mcp/documents', + data: { + title: input.title, + content: input.content, + parent_id: input.parentId, + }, + }); +} diff --git a/src/frontend/servers/mcp/src/env.ts b/src/frontend/servers/mcp/src/env.ts new file mode 100644 index 000000000..9cd2ae457 --- /dev/null +++ b/src/frontend/servers/mcp/src/env.ts @@ -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'; diff --git a/src/frontend/servers/mcp/src/mcp/buildServer.ts b/src/frontend/servers/mcp/src/mcp/buildServer.ts new file mode 100644 index 000000000..d88a5241b --- /dev/null +++ b/src/frontend/servers/mcp/src/mcp/buildServer.ts @@ -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 { + 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; +} diff --git a/src/frontend/servers/mcp/src/mcp/tools/createDocument.ts b/src/frontend/servers/mcp/src/mcp/tools/createDocument.ts new file mode 100644 index 000000000..0c81aee6f --- /dev/null +++ b/src/frontend/servers/mcp/src/mcp/tools/createDocument.ts @@ -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>; + +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; diff --git a/src/frontend/servers/mcp/src/mcp/tools/readDocument.ts b/src/frontend/servers/mcp/src/mcp/tools/readDocument.ts new file mode 100644 index 000000000..58cbd61c6 --- /dev/null +++ b/src/frontend/servers/mcp/src/mcp/tools/readDocument.ts @@ -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>; + +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; diff --git a/src/frontend/servers/mcp/src/mcp/tools/searchDocuments.ts b/src/frontend/servers/mcp/src/mcp/tools/searchDocuments.ts new file mode 100644 index 000000000..60a1b980f --- /dev/null +++ b/src/frontend/servers/mcp/src/mcp/tools/searchDocuments.ts @@ -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>; + +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; diff --git a/src/frontend/servers/mcp/src/mcp/tools/types.ts b/src/frontend/servers/mcp/src/mcp/tools/types.ts new file mode 100644 index 000000000..05ba0ce65 --- /dev/null +++ b/src/frontend/servers/mcp/src/mcp/tools/types.ts @@ -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; + +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; +}; diff --git a/src/frontend/servers/mcp/src/prm.ts b/src/frontend/servers/mcp/src/prm.ts new file mode 100644 index 000000000..3e13a9f74 --- /dev/null +++ b/src/frontend/servers/mcp/src/prm.ts @@ -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 { + const { data: oauthMetadata } = await axios.get( + 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', + ], + }); +} diff --git a/src/frontend/servers/mcp/src/server.ts b/src/frontend/servers/mcp/src/server.ts new file mode 100644 index 000000000..fe7088841 --- /dev/null +++ b/src/frontend/servers/mcp/src/server.ts @@ -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 { + 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; +} diff --git a/src/frontend/servers/mcp/src/start-server.ts b/src/frontend/servers/mcp/src/start-server.ts new file mode 100644 index 000000000..11a19f296 --- /dev/null +++ b/src/frontend/servers/mcp/src/start-server.ts @@ -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); + }); diff --git a/src/frontend/servers/mcp/tsconfig.build.json b/src/frontend/servers/mcp/tsconfig.build.json new file mode 100644 index 000000000..63419b528 --- /dev/null +++ b/src/frontend/servers/mcp/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/src/frontend/servers/mcp/tsconfig.json b/src/frontend/servers/mcp/tsconfig.json new file mode 100644 index 000000000..a827a6eda --- /dev/null +++ b/src/frontend/servers/mcp/tsconfig.json @@ -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"] +} diff --git a/src/frontend/servers/mcp/vitest.config.mts b/src/frontend/servers/mcp/vitest.config.mts new file mode 100644 index 000000000..765285378 --- /dev/null +++ b/src/frontend/servers/mcp/vitest.config.mts @@ -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)), + }, + }, +}); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index e654d2acc..ffa5bb229 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -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==