mirror of
https://github.com/suitenumerique/docs.git
synced 2026-09-13 05:07:52 +02:00
✨(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:
@@ -9,6 +9,7 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- 🔧(backend) fine tune redis cache options
|
||||
- ✨(mcp) add the docs-mcp server #2646
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts",
|
||||
"exec": "yarn build && yarn start"
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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__"]
|
||||
}
|
||||
@@ -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
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user