diff --git a/.dockerignore b/.dockerignore index a8c6221..0dfa54d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,3 +20,6 @@ database/*.sqlite-journal .phpunit.result.cache docs/*.pdf public/storage + +# Runs on a developer machine, next to Claude Code, never in the image. +mcp diff --git a/.env.production.example b/.env.production.example index 44b7908..ff635d9 100644 --- a/.env.production.example +++ b/.env.production.example @@ -123,3 +123,18 @@ MAIL_USERNAME= MAIL_PASSWORD= MAIL_FROM_ADDRESS="admin@example.org" MAIL_FROM_NAME="${APP_NAME}" + +############################################################################### +# MCP server (landing-mcp) +# +# Holds no credentials: every caller signs in with their own. Its domain is +# added in Dokploy's "Domains" tab, pointing at port 3000. +############################################################################### + +# "true" refuses callers without credentials instead of offering them the +# public API — which is already public, so the default is to allow it. +LANDING_MCP_REQUIRE_AUTH=false + +# Empty on purpose. Set to SITE_PREVIEW_TOKEN's value and anyone who can sign +# in sees every draft through the public tools, whatever their role. +LANDING_MCP_PREVIEW_TOKEN= diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 674fc2e..8242e57 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -79,3 +79,24 @@ jobs: - uses: actions/checkout@v4 - run: docker compose -f docker-compose.test.yml run --rm test + + # The MCP server derives its tools from openapi.yaml, so a change to the + # document can break it without touching a line under mcp/ — two operations + # that map to the same tool name, say. Cheap enough to run on every push. + mcp: + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: mcp/package-lock.json + + - run: npm ci + + - run: npm test diff --git a/.gitignore b/.gitignore index 20d5dba..20702e9 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db +/mcp/node_modules diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..abd9992 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,15 @@ +{ + "mcpServers": { + "landing": { + "command": "node", + "args": ["mcp/server.mjs"], + "env": { + "LANDING_URL": "${LANDING_URL:-http://localhost:8000}", + "LANDING_USERNAME": "${LANDING_USERNAME:-}", + "LANDING_PASSWORD": "${LANDING_PASSWORD:-}", + "LANDING_TOKEN": "${LANDING_TOKEN:-}", + "LANDING_PREVIEW_TOKEN": "${LANDING_PREVIEW_TOKEN:-}" + } + } + } +} diff --git a/README.md b/README.md index f3e3990..a61c310 100644 --- a/README.md +++ b/README.md @@ -1332,6 +1332,99 @@ Va **commiteado** —quien lo necesita debería poder leerlo sin ejecutar nada viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su prosa, y un tipo a secas no la conserva. +## Servidor MCP + +[`mcp/`](mcp/) es un servidor MCP (Node ≥ 20) que da a Claude —o a cualquier +cliente MCP— las dos APIs como herramientas: `public_projects_index`, +`admin_pages_update`, `admin_roles_catalogue`… Una por operación documentada, +con el nombre de la ruta de Laravel en la que acaba. + +**No hay ninguna escrita a mano.** Lee el `openapi.yaml` que sirve la +aplicación a la que apunta y las genera de ahí, así que describe la API con la +que está hablando —local, staging o producción— y no la de este checkout. Por lo +mismo que los tipos: el documento lo sujetan los tests de contrato, y una +segunda descripción de la API escrita a mano no la sujetaría nada. La +introducción del documento (las dos APIs, idiomas, campos traducibles) viaja +como instrucciones del servidor. + +### Desplegado (HTTP) + +Va en el stack como un contenedor más, `landing-mcp`, que habla con +`landing-app` por la red interna. En Dokploy, añádele un dominio en la pestaña +«Domains» apuntando al puerto 3000; el endpoint es `https:///mcp` +(Streamable HTTP, sin estado). + +**No tiene credenciales propias.** Cada petición trae las de quien la hace, y +la API aplica los permisos de esa persona, igual que en el panel: + +```bash +claude mcp add --transport http landing https://mcp.example.org/mcp \ + --header "Authorization: Basic $(printf 'tu@email.com:contraseña' | base64)" +``` + +- **`Basic`** con el email y la contraseña de Pireon: el servidor hace login la + primera vez, guarda la sesión en memoria (indexada por un hash, nunca en + disco ni en logs), refresca el token antes de que caduque y la olvida tras + 12 horas sin uso. Es lo recomendable, porque los tokens de Pireon caducan. +- **`Bearer`** con un token de Pireon: funciona hasta que caduca. Se valida + contra `GET /v1/me` y se revalida cada minuto, como hace la propia app. +- **Sin cabecera**: solo la API pública, que ya es pública. + `LANDING_MCP_REQUIRE_AUTH=true` lo cierra. + +Unas credenciales rechazadas se recuerdan un minuto, sin volver a preguntar a +la app. Por eso mismo `POST /v1/auth/token` limita **por cuenta y dirección** +(6/min) y no solo por dirección (con un techo de 30/min por dirección): todos +los logins del MCP salen del mismo contenedor, y un límite por IP haría que la +contraseña mal escrita de uno —o los intentos de un extraño— dejaran fuera a +todos los demás. + +El documento se relee cada minuto, así que un endpoint nuevo desplegado en la +app aparece como herramienta sin redesplegar el MCP; si la app está caída, se +sigue usando el último que cargó. + +`LANDING_MCP_PREVIEW_TOKEN` está vacío a propósito: con el valor de +`SITE_PREVIEW_TOKEN`, cualquiera que pueda iniciar sesión ve todos los +borradores por las herramientas públicas, sea cual sea su rol. Nunca se manda +a quien llega sin credenciales. + +### En local (stdio) + +```bash +cd mcp && npm install +``` + +En este repo ya está registrado en [`.mcp.json`](.mcp.json), que lo lanza por +stdio con la configuración del entorno. Aquí sí las credenciales van en +variables, porque quien lo lanza es quien inicia sesión: + +``` +LANDING_URL raíz de la aplicación (por defecto http://localhost:8000) +LANDING_USERNAME credenciales de Pireon para la API de administración; +LANDING_PASSWORD el token se pide y se refresca solo +LANDING_TOKEN o un bearer token fijo en vez de credenciales +LANDING_PREVIEW_TOKEN opcional: las herramientas públicas ven borradores +LANDING_OPENAPI opcional: ruta o URL de la especificación +``` + +`node mcp/server.mjs --http` arranca el modo HTTP en local (en `127.0.0.1:3000` +salvo `LANDING_MCP_HOST`/`LANDING_MCP_PORT`), para probarlo sin Docker. + +### En los dos modos + +- **Sin credenciales solo aparece la mitad pública.** Ofrecer herramientas que + solo pueden fallar le cuesta al modelo una llamada cada una para descubrirlo. +- **Fuera a propósito**: `POST /v1/auth/token` (es cosa del cliente), la + telemetría (la manda el frontend con su propio secreto) y los dos formularios + del buzón — una llamada a `contact` o `apply` mete una fila en el buzón y + avisa a gente real. +- Las cabeceras no se exponen como parámetros: son secretos, y salen de la + configuración, no del modelo. + +`npm test` (en `mcp/`, y en CI) genera las herramientas desde el +`openapi.yaml` de este repo —un cambio en el documento que haga chocar dos +nombres rompe el build aquí, no la sesión de alguien— y prueba el modo HTTP de +punta a punta contra una app simulada. + ## Comandos ```bash @@ -1458,7 +1551,7 @@ párrafo aparte sin relación programática con él. ## Estructura ``` -docker-compose.yml stack de producción (landing-app, worker, scheduler, postgres) +docker-compose.yml stack de producción (landing-app, worker, scheduler, landing-mcp, postgres) docker-compose.test.yml la suite contra Postgres, desechable Dockerfile imagen única para los tres contenedores PHP docker/ Caddyfile, php.ini y entrypoint @@ -1491,6 +1584,7 @@ config/site.php frontend público, preview, caché, rate config/media.php disco, anchos y formatos de imagen config/git.php proveedores de git y etiquetas de issues public/openapi.yaml especificación de ambas APIs +mcp/ servidor MCP (stdio y HTTP) generado desde la anterior ``` ## Despliegue con Docker diff --git a/docker-compose.yml b/docker-compose.yml index e6aca2c..3646a85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ ############################################################################### # Production stack. # -# Four containers and one database. Deliberately no Redis: Postgres holds the +# Five containers and one database. Deliberately no Redis: Postgres holds the # cache, the sessions and the queue, which for a site this size is plenty and # is one less thing to run, back up and forget to secure. Swapping it in later # is three environment variables. @@ -210,6 +210,45 @@ services: condition: service_healthy stop_grace_period: 30s + # The MCP server (mcp/), so Claude and other MCP clients can use the API + # from anywhere, not only from a checkout of this repository. It reaches the + # application over the stack's own network rather than the public domain: + # one hop, no TLS, and it keeps working while the domain is misconfigured. + # + # Holds no credentials of its own. Every request brings its caller's Pireon + # credentials, and the API applies that person's permissions — see the + # README's "Servidor MCP" section for how to connect. + # + # Like `landing-app`, published only through `dokploy-network`: add its + # domain in Dokploy's "Domains" tab, pointing at port 3000, and the endpoint + # is https:///mcp. + landing-mcp: + build: + context: ./mcp + image: landing-mcp:latest + restart: unless-stopped + environment: + LANDING_URL: http://landing-app:8080 + LANDING_MCP_REQUIRE_AUTH: ${LANDING_MCP_REQUIRE_AUTH:-false} + # Deliberately not SITE_PREVIEW_TOKEN by default: with it, anyone who can + # sign in sees every draft through the public tools, whatever their role + # would let them see in the panel. Set it to that value knowingly. + LANDING_PREVIEW_TOKEN: ${LANDING_MCP_PREVIEW_TOKEN:-} + expose: + - "3000" + networks: + - dokploy-network + - default + depends_on: + landing-app: + condition: service_healthy + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/up').then(r => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] + interval: 30s + timeout: 5s + start_period: 10s + retries: 3 + postgres: image: postgres:17-alpine restart: unless-stopped diff --git a/mcp/.dockerignore b/mcp/.dockerignore new file mode 100644 index 0000000..ceb8b98 --- /dev/null +++ b/mcp/.dockerignore @@ -0,0 +1,4 @@ +node_modules +test +Dockerfile +.dockerignore diff --git a/mcp/Dockerfile b/mcp/Dockerfile new file mode 100644 index 0000000..663a4ce --- /dev/null +++ b/mcp/Dockerfile @@ -0,0 +1,26 @@ +# syntax=docker/dockerfile:1 + +############################################################################### +# The MCP server, over HTTP. Its own image rather than a fourth command in the +# PHP one: it shares no code with the application, only the API between them, +# and Node is a runtime that image has no other reason to carry. +############################################################################### +FROM node:22-alpine + +WORKDIR /app + +ENV NODE_ENV=production \ + LANDING_MCP_HOST=0.0.0.0 \ + LANDING_MCP_PORT=3000 + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY server.mjs ./ +COPY src ./src + +USER node + +EXPOSE 3000 + +CMD ["node", "server.mjs", "--http"] diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 0000000..ba9a7e4 --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,1222 @@ +{ + "name": "landing-mcp", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "landing-mcp", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.1", + "yaml": "^2.9.1" + }, + "bin": { + "landing-mcp": "server.mjs" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.1.tgz", + "integrity": "sha512-H2HxLvC3HDNybePJaLdSrU1hhUK5iQw+WvV1b01myFyI7sdVGe1u/IPTE5D9fGCiJDVtgMV/lmFkQXLmQyIFYA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.9.tgz", + "integrity": "sha512-7dMkQmZoC4E6F7AtaQSPhlWAdnBti+j7rreMZl8QB4jFiEhP9TWbGWUMi8WYzBCgmgulxuvLQupKqo+Co6Omyg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..e32521b --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,20 @@ +{ + "name": "landing-mcp", + "private": true, + "type": "module", + "description": "MCP server exposing the landing REST API as tools, generated from its OpenAPI document.", + "bin": { + "landing-mcp": "server.mjs" + }, + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node server.mjs", + "test": "node --test" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.1", + "yaml": "^2.9.1" + } +} diff --git a/mcp/server.mjs b/mcp/server.mjs new file mode 100644 index 0000000..8f61019 --- /dev/null +++ b/mcp/server.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * MCP server for the landing API. + * + * Reads the OpenAPI document the running application serves and offers one + * tool per documented operation, so it always describes the API it is talking + * to — local, staging or production — rather than the one in this checkout. + * + * node server.mjs stdio, for a client that launches it locally + * node server.mjs --http Streamable HTTP on /mcp, for deploying + * + * Configuration, all through the environment: + * + * LANDING_URL Application root. Default http://localhost:8000 + * LANDING_OPENAPI Path or URL of the spec, if not LANDING_URL/openapi.yaml. + * LANDING_PREVIEW_TOKEN Lets the public tools see unpublished content. Over + * HTTP, only for signed-in callers. + * + * stdio only — the one person running it is the one it signs in as: + * + * LANDING_USERNAME Pireon credentials for the admin API (/v1). The + * LANDING_PASSWORD token is obtained and refreshed automatically. + * LANDING_TOKEN Or a fixed bearer token instead. + * + * HTTP only — every request brings its own credentials (see src/http.mjs): + * + * LANDING_MCP_PORT Default 3000. + * LANDING_MCP_HOST Default 127.0.0.1; the image sets 0.0.0.0. + * LANDING_MCP_REQUIRE_AUTH "true" refuses anonymous callers instead of + * offering them the public API. + * + * stdout is the stdio protocol channel: anything for a human goes to stderr. + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { Catalogue } from './src/catalogue.mjs'; +import { LandingClient } from './src/client.mjs'; +import { startHttpServer } from './src/http.mjs'; +import { createMcpServer } from './src/mcp.mjs'; + +const env = (name) => process.env[name]?.trim() || undefined; + +const baseUrl = (env('LANDING_URL') ?? 'http://localhost:8000').replace(/\/+$/, ''); +const http = process.argv.includes('--http'); + +const catalogue = new Catalogue({ + source: env('LANDING_OPENAPI') ?? `${baseUrl}/openapi.yaml`, + baseUrl, + // A deployment outlives many deploys of the application; a stdio process + // lives for one editor session and describes its tools once anyway. + ttlMs: http ? 60_000 : Infinity, +}); + +if (http) { + // Credentials in the environment would sign every anonymous caller in as + // whoever they belong to. + for (const name of ['LANDING_USERNAME', 'LANDING_PASSWORD', 'LANDING_TOKEN']) { + if (env(name)) console.error(`landing-mcp: ${name} is ignored over HTTP; each request brings its own credentials`); + } + + // Tried now only to report a misconfiguration early. The application may + // well still be starting, so a failure here is not fatal: the catalogue + // retries on the first request. + catalogue.get().catch((error) => console.error(`landing-mcp: OpenAPI document not available yet: ${error.message}`)); + + startHttpServer({ + catalogue, + port: Number(env('LANDING_MCP_PORT') ?? 3000), + host: env('LANDING_MCP_HOST') ?? '127.0.0.1', + previewToken: env('LANDING_PREVIEW_TOKEN'), + requireAuth: env('LANDING_MCP_REQUIRE_AUTH') === 'true', + }); +} else { + let current; + + try { + current = await catalogue.get(); + } catch (error) { + console.error(`landing-mcp: could not load the OpenAPI document from ${catalogue.source}: ${error.message}`); + process.exit(1); + } + + const client = new LandingClient({ + apiBase: current.apiBase, + token: env('LANDING_TOKEN'), + username: env('LANDING_USERNAME'), + password: env('LANDING_PASSWORD'), + previewToken: env('LANDING_PREVIEW_TOKEN'), + }); + + await createMcpServer({ spec: current.spec, tools: current.tools, client }).connect(new StdioServerTransport()); + + console.error(`landing-mcp: stdio against ${current.apiBase}${client.canAuthenticate() ? '' : ' (public API only: no credentials)'}`); +} diff --git a/mcp/src/catalogue.mjs b/mcp/src/catalogue.mjs new file mode 100644 index 0000000..97c74b2 --- /dev/null +++ b/mcp/src/catalogue.mjs @@ -0,0 +1,78 @@ +/** + * The OpenAPI document and the tools derived from it. + * + * Loaded from the application the server talks to, and — for a long-running + * deployment — reloaded now and then, so that deploying a new endpoint to the + * application makes it a tool here without also redeploying this. + */ + +import { readFile } from 'node:fs/promises'; +import { parse } from 'yaml'; +import { buildTools } from './tools.mjs'; + +export class Catalogue { + /** + * @param {object} options + * @param {string} options.source Path or URL of openapi.yaml. + * @param {string} options.baseUrl Application root the document's server URL is relative to. + * @param {number} [options.ttlMs] How long a loaded document is trusted. Infinity: load once. + */ + constructor({ source, baseUrl, ttlMs = Infinity }) { + this.source = source; + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.ttlMs = ttlMs; + this.current = null; + this.loadedAt = 0; + this.pending = null; + } + + /** @returns {Promise<{spec: object, tools: import('./tools.mjs').Tool[], apiBase: string}>} */ + async get() { + if (this.current && Date.now() - this.loadedAt < this.ttlMs) return this.current; + + this.pending ??= this.load().finally(() => { this.pending = null; }); + + try { + return await this.pending; + } catch (error) { + // An application that is redeploying, or briefly down, should not take + // the tools with it: the last document that loaded is still the best + // description available. Only the very first load has nothing to fall + // back on. + if (this.current) { + console.error(`landing-mcp: keeping the previous OpenAPI document: ${error.message}`); + this.loadedAt = Date.now(); + + return this.current; + } + + throw error; + } + } + + async load() { + const spec = parse(await this.read()); + + // The document's own server URL ("/api"), relative to the application root. + const serverUrl = spec.servers?.[0]?.url ?? '/api'; + + this.current = { + spec, + tools: buildTools(spec), + apiBase: /^https?:\/\//.test(serverUrl) ? serverUrl : `${this.baseUrl}${serverUrl}`, + }; + this.loadedAt = Date.now(); + + return this.current; + } + + async read() { + if (!/^https?:\/\//.test(this.source)) return readFile(this.source, 'utf8'); + + const response = await fetch(this.source, { signal: AbortSignal.timeout(15_000) }); + + if (!response.ok) throw new Error(`GET ${this.source} answered ${response.status}`); + + return response.text(); + } +} diff --git a/mcp/src/client.mjs b/mcp/src/client.mjs new file mode 100644 index 0000000..e558125 --- /dev/null +++ b/mcp/src/client.mjs @@ -0,0 +1,198 @@ +/** + * Calls the API on the model's behalf, and keeps it signed in. + * + * Tokens come from the Pireon identity server through POST /api/v1/auth/token + * and expire, so a fixed bearer token in the MCP config would die mid-session. + * With credentials instead, this client logs in on the first authenticated + * call, refreshes before expiry, and retries once on a 401 — a token revoked or + * expired upstream costs one extra round trip, not a broken session. + */ + +// Refresh this long before the stated expiry, so a token never expires between +// the check and the request. +const EXPIRY_MARGIN_MS = 60_000; + +const TIMEOUT_MS = 30_000; + +export class LandingClient { + /** + * @param {object} options + * @param {string} options.apiBase e.g. https://admin.example.com/api + * @param {string} [options.token] A fixed bearer token. + * @param {string} [options.username] + * @param {string} [options.password] + * @param {string} [options.previewToken] Sent on public calls to see drafts. + * @param {typeof fetch} [options.fetch] + */ + constructor({ apiBase, token, username, password, previewToken, fetch = globalThis.fetch }) { + this.apiBase = apiBase.replace(/\/+$/, ''); + this.fixedToken = token || null; + this.credentials = username && password ? { username, password } : null; + this.previewToken = previewToken || null; + this.fetch = fetch; + + /** @type {{access: string, refresh: string|null, expiresAt: number|null}|null} */ + this.session = null; + this.pending = null; + } + + canAuthenticate() { + return Boolean(this.fixedToken || this.credentials); + } + + /** + * Proves the credentials or token work, by spending them on GET /v1/me. + * Throws for credentials the application refused; resolves the response + * otherwise, for the caller to judge its status. + */ + async verify() { + return this.call({ method: 'GET', path: '/v1/me', params: [], hasBody: false, requiresAuth: true }); + } + + /** + * @param {import('./tools.mjs').Operation} operation + * @param {Record} args + * @returns {Promise<{status: number, body: unknown}>} + */ + async call(operation, args = {}) { + const request = this.buildRequest(operation, args); + + if (!operation.requiresAuth) { + if (this.previewToken) request.headers['X-Preview-Token'] = this.previewToken; + + return this.send(request); + } + + request.headers.Authorization = `Bearer ${await this.accessToken()}`; + + let response = await this.send(request); + + // A fixed token has nothing to renew itself with, so its 401 stands. + if (response.status === 401 && !this.fixedToken) { + this.session = { ...this.session, access: null }; + request.headers.Authorization = `Bearer ${await this.accessToken()}`; + response = await this.send(request); + } + + return response; + } + + buildRequest(operation, args) { + let path = operation.path; + const query = new URLSearchParams(); + + for (const { name, in: location } of operation.params) { + const value = args[name]; + + if (location === 'path') { + if (value === undefined || value === null || value === '') { + throw new Error(`Missing path parameter "${name}"`); + } + + path = path.replace(`{${name}}`, encodeURIComponent(String(value))); + } else if (value !== undefined && value !== null) { + // Laravel's boolean() and the `boolean` rule both accept 1/0; only the + // former accepts "true". + query.set(name, typeof value === 'boolean' ? (value ? '1' : '0') : String(value)); + } + } + + const headers = { Accept: 'application/json' }; + let body; + + if (operation.hasBody && args.body !== undefined) { + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(args.body); + } + + const qs = query.toString(); + + return { + url: `${this.apiBase}${path}${qs ? `?${qs}` : ''}`, + method: operation.method, + headers, + body, + }; + } + + async send({ url, method, headers, body }) { + const response = await this.fetch(url, { + method, + headers, + body, + signal: AbortSignal.timeout(TIMEOUT_MS), + }); + + const text = await response.text(); + let parsed = text; + + try { + parsed = text ? JSON.parse(text) : null; + } catch { + // Not JSON (an HTML error page from a proxy, say): hand it over as text. + } + + return { status: response.status, body: parsed }; + } + + async accessToken() { + if (this.fixedToken) return this.fixedToken; + + if (!this.credentials) { + throw new Error('This endpoint needs a token: set LANDING_TOKEN, or LANDING_USERNAME and LANDING_PASSWORD.'); + } + + const session = this.session; + const fresh = session?.access + && (session.expiresAt === null || session.expiresAt - Date.now() > EXPIRY_MARGIN_MS); + + if (fresh) return session.access; + + // Parallel tool calls would otherwise each log in, and the token endpoint + // allows six requests a minute. + this.pending ??= this.renew().finally(() => { this.pending = null; }); + + return (await this.pending).access; + } + + async renew() { + if (this.session?.refresh) { + try { + return await this.exchange({ grant_type: 'refresh_token', refresh_token: this.session.refresh }); + } catch { + // Refresh tokens expire and get revoked too; the password still works. + } + } + + return this.exchange({ grant_type: 'password', ...this.credentials }); + } + + async exchange(grant) { + const { status, body } = await this.send({ + url: `${this.apiBase}/v1/auth/token`, + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify(grant), + }); + + if (status !== 200 || !body?.access_token) { + const reason = body?.message ?? (typeof body === 'string' ? body.slice(0, 200) : `HTTP ${status}`); + const error = new Error(`Could not obtain a token (${grant.grant_type} grant): ${reason}`); + + // 401/403/422 are the application's answer about these credentials; + // anything else says nothing about them. The HTTP transport needs to + // tell the two apart. + error.status = status; + + throw error; + } + + this.session = { + access: body.access_token, + refresh: body.refresh_token ?? null, + expiresAt: body.expires_at ? Date.parse(body.expires_at) : null, + }; + + return this.session; + } +} diff --git a/mcp/src/http.mjs b/mcp/src/http.mjs new file mode 100644 index 0000000..12c22d9 --- /dev/null +++ b/mcp/src/http.mjs @@ -0,0 +1,288 @@ +/** + * The MCP server over Streamable HTTP, for deploying next to the application. + * + * Stateless: every POST builds a fresh MCP server around the caller's client, + * so nothing about one request survives into the next except the sign-in + * below, and any number of replicas can sit behind the same domain. + * + * Nobody signs in to this server as such. Each request carries its caller's + * own credentials, and the API applies that person's permissions exactly as the + * panel would: + * + * Authorization: Basic base64(email:password) Pireon credentials. Signed + * in once, then kept fresh. + * Authorization: Bearer A Pireon access token. + * (none) Public API only, unless + * LANDING_MCP_REQUIRE_AUTH. + * + * Credentials never reach a log, and are held only in memory, keyed by a hash, + * for as long as they are in use. + */ + +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { LandingClient } from './client.mjs'; +import { createMcpServer } from './mcp.mjs'; + +const MAX_BODY_BYTES = 1_000_000; + +// A refused credential is refused again without asking the application, for +// this long. A client retrying in a loop with a wrong password would otherwise +// spend the account's sign-in budget for its owner too. +const REJECTION_TTL_MS = 60_000; + +// A bearer token is re-checked this often, matching the application's own +// introspection cache: past that, a token revoked upstream stops working here +// at the same moment it stops working there. +const BEARER_TTL_MS = 60_000; + +// A signed-in session nobody has used for this long is forgotten, password +// included. +const IDLE_MS = 12 * 60 * 60 * 1000; +const MAX_SESSIONS = 500; + +class HttpError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +/** + * @param {object} options + * @param {import('./catalogue.mjs').Catalogue} options.catalogue + * @param {number} options.port + * @param {string} options.host + * @param {string} [options.previewToken] Only ever sent for signed-in callers. + * @param {boolean} [options.requireAuth] + */ +export function startHttpServer({ catalogue, port, host, previewToken, requireAuth = false }) { + const sessions = new Sessions({ previewToken, requireAuth }); + + const server = createServer((req, res) => { + handle(req, res, { catalogue, sessions }).catch((error) => { + console.error(`landing-mcp: ${error.stack ?? error.message}`); + + if (!res.headersSent) sendError(res, 500, 'Internal error'); + }); + }); + + server.listen(port, host, () => { + console.error(`landing-mcp: listening on http://${host}:${port}/mcp${requireAuth ? '' : ' (anonymous callers get the public API)'}`); + }); + + // PID 1 in a container: without this, `docker stop` waits out its timeout + // and then kills whatever request was in flight. + for (const signal of ['SIGTERM', 'SIGINT']) { + process.once(signal, () => server.close(() => process.exit(0))); + } + + return server; +} + +async function handle(req, res, { catalogue, sessions }) { + const { pathname } = new URL(req.url, 'http://localhost'); + + // Liveness only, like the application's /up: it must not fail because the + // application is redeploying, or both would restart each other. + if (pathname === '/up') return send(res, 200, { status: 'ok' }); + + if (pathname !== '/mcp') return sendError(res, 404, 'Not found'); + + // Stateless: there is no session to open a stream on or to delete. + if (req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + + return sendError(res, 405, 'This server is stateless and only accepts POST'); + } + + let current; + + try { + current = await catalogue.get(); + } catch (error) { + return sendError(res, 503, `The API description is not available: ${error.message}`); + } + + let client; + + try { + client = await sessions.authenticate(req.headers.authorization, current.apiBase); + } catch (error) { + if (!(error instanceof HttpError)) throw error; + + if (error.status === 401) res.setHeader('WWW-Authenticate', 'Basic realm="landing-mcp", charset="UTF-8"'); + + return sendError(res, error.status, error.message); + } + + let body; + + try { + body = await readJson(req); + } catch (error) { + return sendError(res, error.status ?? 400, error.message); + } + + const mcp = createMcpServer({ spec: current.spec, tools: current.tools, client }); + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + + res.on('close', () => { + transport.close(); + mcp.close(); + }); + + await mcp.connect(transport); + await transport.handleRequest(req, res, body); +} + +class Sessions { + constructor({ previewToken, requireAuth }) { + this.previewToken = previewToken; + this.requireAuth = requireAuth; + + /** @type {Map, lastUsed: number, expiresAt: number}>} */ + this.entries = new Map(); + /** @type {Map} */ + this.rejected = new Map(); + this.anonymous = null; + } + + /** @returns {Promise} */ + async authenticate(header, apiBase) { + if (!header) { + if (this.requireAuth) throw new HttpError(401, 'Authentication required: send Basic credentials or a Bearer token'); + + // No preview secret: that would hand drafts to anyone who can reach this. + this.anonymous ??= new LandingClient({ apiBase }); + + return this.anonymous; + } + + const credentials = parseAuthorization(header); + + if (!credentials) throw new HttpError(401, 'Unsupported Authorization header: use Basic or Bearer'); + + const now = Date.now(); + const key = createHash('sha256').update(header).digest('hex'); + + this.sweep(now); + + if ((this.rejected.get(key) ?? 0) > now) throw new HttpError(401, 'Credentials refused by the application'); + + const entry = this.entries.get(key); + + if (entry && entry.expiresAt > now) { + entry.lastUsed = now; + + return entry.client; + } + + // The promise, not the client, so concurrent first requests with the same + // credentials sign in once between them. + const client = this.open(credentials, apiBase); + + this.entries.set(key, { + client, + lastUsed: now, + expiresAt: credentials.token ? now + BEARER_TTL_MS : Infinity, + }); + + try { + return await client; + } catch (error) { + this.entries.delete(key); + + if (error instanceof HttpError && error.status === 401) this.rejected.set(key, Date.now() + REJECTION_TTL_MS); + + throw error; + } + } + + async open(credentials, apiBase) { + const client = new LandingClient({ apiBase, ...credentials, previewToken: this.previewToken }); + let response; + + try { + response = await client.verify(); + } catch (error) { + if ([401, 403, 422].includes(error.status)) throw new HttpError(401, 'Credentials refused by the application'); + if (error.status === 429) throw new HttpError(429, 'Too many sign-in attempts for this account; wait a minute'); + + throw new HttpError(502, `Could not reach the application: ${error.message}`); + } + + if (response.status === 401 || response.status === 403) { + throw new HttpError(401, 'Token refused by the application'); + } + + if (response.status !== 200) throw new HttpError(502, `The application answered ${response.status} to GET /v1/me`); + + return client; + } + + sweep(now) { + for (const [key, until] of this.rejected) { + if (until <= now) this.rejected.delete(key); + } + + for (const [key, entry] of this.entries) { + if (now - entry.lastUsed > IDLE_MS || entry.expiresAt <= now) this.entries.delete(key); + } + + // Insertion order is sign-in order: the oldest goes first. + while (this.entries.size >= MAX_SESSIONS) this.entries.delete(this.entries.keys().next().value); + } +} + +/** @returns {{username: string, password: string}|{token: string}|null} */ +export function parseAuthorization(header) { + const match = /^(\w+)\s+(\S+)\s*$/.exec(header); + + if (!match) return null; + + const [, scheme, value] = match; + + if (scheme.toLowerCase() === 'bearer') return { token: value }; + + if (scheme.toLowerCase() === 'basic') { + const decoded = Buffer.from(value, 'base64').toString('utf8'); + const colon = decoded.indexOf(':'); + + if (colon < 1 || colon === decoded.length - 1) return null; + + return { username: decoded.slice(0, colon), password: decoded.slice(colon + 1) }; + } + + return null; +} + +async function readJson(req) { + const chunks = []; + let size = 0; + + for await (const chunk of req) { + size += chunk.length; + + if (size > MAX_BODY_BYTES) throw new HttpError(413, 'Request body too large'); + + chunks.push(chunk); + } + + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + throw new HttpError(400, 'Request body is not valid JSON'); + } +} + +function send(res, status, payload) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +// Shaped as a JSON-RPC error, which is what an MCP client knows how to show. +function sendError(res, status, message) { + send(res, status, { jsonrpc: '2.0', error: { code: -32000, message }, id: null }); +} diff --git a/mcp/src/mcp.mjs b/mcp/src/mcp.mjs new file mode 100644 index 0000000..d945b63 --- /dev/null +++ b/mcp/src/mcp.mjs @@ -0,0 +1,52 @@ +/** + * The MCP server itself, independent of the transport it is reached through. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +/** + * @param {object} options + * @param {object} options.spec + * @param {import('./tools.mjs').Tool[]} options.tools + * @param {import('./client.mjs').LandingClient} options.client + */ +export function createMcpServer({ spec, tools, client }) { + // Offering tools that can only fail would cost the model a round trip each + // to learn that; without credentials the admin half is simply not there. + const offered = tools.filter((tool) => !tool.operation.requiresAuth || client.canAuthenticate()); + const byName = new Map(offered.map((tool) => [tool.name, tool])); + + const server = new Server( + { name: 'landing', version: spec.info?.version ?? '1.0.0' }, + { + capabilities: { tools: {} }, + // The document's introduction already explains the two APIs, auth and + // how translatable fields are read and written. + instructions: spec.info?.description, + }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: offered.map(({ operation, ...tool }) => tool), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const tool = byName.get(request.params.name); + + if (!tool) { + return { isError: true, content: [{ type: 'text', text: `Unknown tool ${request.params.name}` }] }; + } + + try { + const { status, body } = await client.call(tool.operation, request.params.arguments ?? {}); + const payload = body === null || body === '' ? '' : `\n${typeof body === 'string' ? body : JSON.stringify(body)}`; + + return { isError: status >= 400, content: [{ type: 'text', text: `HTTP ${status}${payload}` }] }; + } catch (error) { + return { isError: true, content: [{ type: 'text', text: error.message }] }; + } + }); + + return server; +} diff --git a/mcp/src/tools.mjs b/mcp/src/tools.mjs new file mode 100644 index 0000000..442f837 --- /dev/null +++ b/mcp/src/tools.mjs @@ -0,0 +1,303 @@ +/** + * Turns the API's OpenAPI document into MCP tools, one per operation. + * + * The tools are derived rather than written by hand for the same reason the + * TypeScript types are: `openapi.yaml` is held to the truth by the contract + * tests, and nothing would hold a second, hand-written description of the API + * to anything. A new endpoint documented there is a new tool here with no + * change to this file. + */ + +// Endpoints that exist for someone other than an operator. Telemetry is the +// frontend reporting back with its own secret; the submissions are a visitor's +// forms, and a call to one puts a row in the inbox and notifies real people. +const EXCLUDED_TAGS = new Set(['Telemetry', 'Submissions']); + +// Obtaining a token is the client's job, not the model's. +const EXCLUDED_OPERATIONS = new Set(['POST /v1/auth/token']); + +const METHODS = ['get', 'post', 'put', 'patch', 'delete']; + +// OpenAPI keywords that either mean nothing to JSON Schema or only describe +// responses. `nullable` is translated rather than dropped. +const DROPPED_KEYWORDS = new Set([ + 'nullable', 'example', 'readOnly', 'writeOnly', 'discriminator', 'xml', 'externalDocs', +]); + +/** + * @typedef {object} Operation + * @property {string} method Upper-case HTTP method. + * @property {string} path Path as documented, e.g. /v1/pages/{page}. + * @property {{name: string, in: string}[]} params + * @property {boolean} hasBody + * @property {boolean} requiresAuth + * + * @typedef {object} Tool + * @property {string} name + * @property {string} description + * @property {object} inputSchema + * @property {object} annotations + * @property {Operation} operation Kept server-side; never sent to the client. + */ + +/** + * @param {object} spec The parsed OpenAPI document. + * @returns {Tool[]} + */ +export function buildTools(spec) { + const paths = Object.keys(spec.paths ?? {}); + const tools = []; + + for (const path of paths) { + const item = spec.paths[path]; + + for (const method of METHODS) { + const op = item[method]; + + if (!op) continue; + if (EXCLUDED_OPERATIONS.has(`${method.toUpperCase()} ${path}`)) continue; + if ((op.tags ?? []).some((tag) => EXCLUDED_TAGS.has(tag))) continue; + + tools.push(buildTool(spec, paths, path, method, item, op)); + } + } + + // A clash would silently shadow one endpoint behind another, so it is a + // startup failure rather than something to discover by calling the wrong one. + const seen = new Map(); + + for (const tool of tools) { + const { method, path } = tool.operation; + + if (seen.has(tool.name)) { + throw new Error(`Tool name "${tool.name}" is derived from both ${seen.get(tool.name)} and ${method} ${path}`); + } + + seen.set(tool.name, `${method} ${path}`); + } + + return tools; +} + +function buildTool(spec, paths, path, method, item, op) { + const params = mergeParameters(spec, item.parameters, op.parameters) + // Headers are credentials here (preview and ingest secrets), which come + // from the server's configuration, never from the model. + .filter((param) => param.in === 'path' || param.in === 'query'); + + const properties = {}; + const required = []; + + for (const param of params) { + const schema = toJsonSchema(spec, param.schema ?? { type: 'string' }); + + properties[param.name] = param.description + ? { ...schema, description: param.description.trim() } + : schema; + + if (param.required || param.in === 'path') required.push(param.name); + } + + const bodySchema = op.requestBody?.content?.['application/json']?.schema; + + if (bodySchema) { + if ('body' in properties) { + throw new Error(`${method.toUpperCase()} ${path} has a parameter named "body", which the request body needs`); + } + + const body = toJsonSchema(spec, bodySchema); + + // Create and update share one write schema, so its `required` describes + // creation. On a partial update it would force the model to resend fields + // it has no reason to touch. + if (method === 'patch') delete body.required; + + properties.body = { ...body, description: 'JSON request body.' }; + + if (op.requestBody.required) required.push('body'); + } + + const security = op.security ?? spec.security ?? []; + + return { + name: toolName(paths, path, method), + description: describe(path, method, op, security.length > 0), + inputSchema: { + type: 'object', + properties, + ...(required.length ? { required } : {}), + additionalProperties: false, + }, + annotations: { + title: op.summary, + readOnlyHint: method === 'get', + destructiveHint: method === 'delete', + idempotentHint: method === 'get' || method === 'put' || method === 'delete', + }, + operation: { + method: method.toUpperCase(), + path, + params: params.map(({ name, in: location }) => ({ name, in: location })), + hasBody: Boolean(bodySchema), + requiresAuth: security.length > 0, + }, + }; +} + +/** + * Named after the Laravel route the operation lands on, so a tool reads the + * same as `routes/api.php`: /v1/pages/{page} under PATCH is admin_pages_update, + * /public/v1/projects/{slug} under GET is public_projects_show. + */ +export function toolName(paths, path, method) { + const segments = path.split('/').filter(Boolean).filter((s) => s !== 'v1'); + const scope = segments[0] === 'public' ? 'public' : 'admin'; + + if (segments[0] === 'public') segments.shift(); + + const isParam = (segment) => segment.startsWith('{'); + const endsInParam = isParam(segments.at(-1)); + const base = segments.filter((s) => !isParam(s)).map((s) => s.replaceAll('-', '_')); + + let action = null; + + if (endsInParam) { + action = { get: 'show', put: 'update', patch: 'update', delete: 'destroy' }[method]; + } else if (method === 'get') { + // A collection has an item route next to it; /site or /radar does not, + // and is named after itself alone. + const hasItemRoute = paths.some((p) => p.startsWith(`${path}/{`)); + action = hasItemRoute ? 'index' : null; + } else if (method === 'post') { + // /pages/{page}/restore is an action named by its last segment already. + action = segments.length > 1 && isParam(segments.at(-2)) ? null : 'store'; + } else { + action = method; + } + + return [scope, ...base, ...(action ? [action] : [])].join('_'); +} + +function describe(path, method, op, requiresAuth) { + const parts = [op.summary, op.description].filter(Boolean).map((s) => s.trim()); + + parts.push(`${method.toUpperCase()} /api${path} · ${requiresAuth ? 'authenticated' : 'public, no token'}`); + + return parts.join('\n\n'); +} + +/** + * Path-level parameters apply to every operation under the path; an operation + * may redeclare one, and its own declaration wins. + */ +function mergeParameters(spec, pathLevel = [], operationLevel = []) { + const merged = new Map(); + + for (const raw of [...pathLevel, ...operationLevel]) { + const param = resolve(spec, raw); + merged.set(`${param.in}:${param.name}`, param); + } + + return [...merged.values()]; +} + +function resolve(spec, node) { + let current = node; + const visited = new Set(); + + while (current && typeof current.$ref === 'string') { + if (visited.has(current.$ref)) throw new Error(`Circular $ref ${current.$ref}`); + + visited.add(current.$ref); + current = lookup(spec, current.$ref); + } + + return current; +} + +function lookup(spec, ref) { + if (!ref.startsWith('#/')) throw new Error(`Only local $refs are supported, got ${ref}`); + + const target = ref + .slice(2) + .split('/') + .map((part) => part.replaceAll('~1', '/').replaceAll('~0', '~')) + .reduce((node, key) => node?.[key], spec); + + if (target === undefined) throw new Error(`Unresolvable $ref ${ref}`); + + return target; +} + +/** + * OpenAPI 3.0 schema → self-contained JSON Schema: references inlined (MCP + * clients receive each tool's schema on its own, with no `components` to point + * into) and `nullable` spelled the way JSON Schema spells it. + */ +export function toJsonSchema(spec, schema, stack = []) { + if (schema === null || typeof schema !== 'object') return schema; + + if (typeof schema.$ref === 'string') { + // A recursive schema cannot be inlined forever; past the first repeat the + // model gets "anything" and the server's validation has the last word. + if (stack.includes(schema.$ref)) return {}; + + const { $ref, nullable: isNullable, ...siblings } = schema; + + return nullable(isNullable, { + ...toJsonSchema(spec, lookup(spec, $ref), [...stack, $ref]), + ...toJsonSchema(spec, siblings, stack), + }); + } + + // `allOf: [{$ref}]` next to a description is how this document annotates a + // shared schema in place. Flattened, the model reads one schema, not a + // composition of one. + if (Array.isArray(schema.allOf) && schema.allOf.length === 1) { + const { allOf, nullable: isNullable, ...siblings } = schema; + + return nullable(isNullable, { + ...toJsonSchema(spec, allOf[0], stack), + ...toJsonSchema(spec, siblings, stack), + }); + } + + const out = {}; + + for (const [key, value] of Object.entries(schema)) { + if (DROPPED_KEYWORDS.has(key)) continue; + + if (key === 'properties') { + out.properties = Object.fromEntries( + Object.entries(value) + // Response-only fields have no business in something the model writes. + .filter(([, property]) => !resolve(spec, property)?.readOnly) + .map(([name, property]) => [name, toJsonSchema(spec, property, stack)]), + ); + } else if (['allOf', 'oneOf', 'anyOf'].includes(key)) { + out[key] = value.map((member) => toJsonSchema(spec, member, stack)); + } else if (['items', 'additionalProperties', 'not'].includes(key) && typeof value === 'object') { + out[key] = toJsonSchema(spec, value, stack); + } else { + out[key] = value; + } + } + + return nullable(schema.nullable, out); +} + +function nullable(isNullable, schema) { + if (!isNullable) return schema; + + if (typeof schema.type === 'string') { + const out = { ...schema, type: [schema.type, 'null'] }; + + if (Array.isArray(out.enum) && !out.enum.includes(null)) out.enum = [...out.enum, null]; + + return out; + } + + const { description, ...rest } = schema; + + return { ...(description ? { description } : {}), anyOf: [rest, { type: 'null' }] }; +} diff --git a/mcp/test/client.test.mjs b/mcp/test/client.test.mjs new file mode 100644 index 0000000..f1a1fbe --- /dev/null +++ b/mcp/test/client.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { LandingClient } from '../src/client.mjs'; + +const showPage = { + method: 'GET', + path: '/v1/pages/{page}', + params: [{ name: 'page', in: 'path' }, { name: 'lang', in: 'query' }], + hasBody: false, + requiresAuth: true, +}; + +const publicProjects = { + method: 'GET', + path: '/public/v1/projects', + params: [{ name: 'featured', in: 'query' }], + hasBody: false, + requiresAuth: false, +}; + +/** A fake fetch that answers from a list of handlers and records every call. */ +function fakeFetch(handler) { + const calls = []; + + const fetch = async (url, init) => { + calls.push({ url, ...init }); + const { status = 200, body = {} } = handler(url, init, calls.length); + + return new Response(status === 204 ? null : JSON.stringify(body), { status }); + }; + + return { fetch, calls }; +} + +const tokenResponse = (access, expiresInMs = 3_600_000) => ({ + access_token: access, + refresh_token: `refresh-${access}`, + expires_at: new Date(Date.now() + expiresInMs).toISOString(), +}); + +test('builds the URL from path and query parameters', async () => { + const { fetch, calls } = fakeFetch(() => ({ body: { data: [] } })); + const client = new LandingClient({ apiBase: 'http://app.test/api/', fetch }); + + await client.call(publicProjects, { featured: true }); + + assert.equal(calls[0].url, 'http://app.test/api/public/v1/projects?featured=1'); + assert.equal(calls[0].headers.Authorization, undefined); +}); + +test('sends the preview secret on public calls only', async () => { + const { fetch, calls } = fakeFetch((url) => ( + url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: {} } + )); + const client = new LandingClient({ + apiBase: 'http://app.test/api', username: 'u', password: 'p', previewToken: 'secret', fetch, + }); + + await client.call(publicProjects, {}); + await client.call(showPage, { page: 1 }); + + assert.equal(calls[0].headers['X-Preview-Token'], 'secret'); + assert.equal(calls.at(-1).headers['X-Preview-Token'], undefined); +}); + +test('logs in once and reuses the token', async () => { + const { fetch, calls } = fakeFetch((url) => ( + url.endsWith('/auth/token') ? { body: tokenResponse('a') } : { body: { data: {} } } + )); + const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch }); + + await Promise.all([client.call(showPage, { page: 1 }), client.call(showPage, { page: 2 })]); + await client.call(showPage, { page: 3 }); + + assert.equal(calls.filter((c) => c.url.endsWith('/auth/token')).length, 1); + assert.equal(calls.at(-1).url, 'http://app.test/api/v1/pages/3'); + assert.equal(calls.at(-1).headers.Authorization, 'Bearer a'); +}); + +test('refreshes a token about to expire', async () => { + let issued = 0; + const { fetch, calls } = fakeFetch((url) => ( + url.endsWith('/auth/token') ? { body: tokenResponse(`t${++issued}`, 10_000) } : { body: {} } + )); + const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch }); + + await client.call(showPage, { page: 1 }); + await client.call(showPage, { page: 1 }); + + const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type); + + assert.deepEqual(grants, ['password', 'refresh_token']); + assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2'); +}); + +test('retries once with a new token after a 401', async () => { + let issued = 0; + const { fetch, calls } = fakeFetch((url, init) => { + if (url.endsWith('/auth/token')) return { body: tokenResponse(`t${++issued}`) }; + + return init.headers.Authorization === 'Bearer t1' ? { status: 401, body: { message: 'Unauthenticated.' } } : { body: { ok: true } }; + }); + const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch }); + + const response = await client.call(showPage, { page: 1 }); + + assert.equal(response.status, 200); + assert.equal(calls.at(-1).headers.Authorization, 'Bearer t2'); +}); + +test('falls back to the password when the refresh token is refused', async () => { + let issued = 0; + const { fetch, calls } = fakeFetch((url, init) => { + if (!url.endsWith('/auth/token')) return { body: {} }; + + return JSON.parse(init.body).grant_type === 'refresh_token' + ? { status: 422, body: { message: 'invalid' } } + : { body: tokenResponse(`t${++issued}`, 10_000) }; + }); + const client = new LandingClient({ apiBase: 'http://app.test/api', username: 'u', password: 'p', fetch }); + + await client.call(showPage, { page: 1 }); + await client.call(showPage, { page: 1 }); + + const grants = calls.filter((c) => c.url.endsWith('/auth/token')).map((c) => JSON.parse(c.body).grant_type); + + assert.deepEqual(grants, ['password', 'refresh_token', 'password']); +}); + +test('a fixed token is used as is and never renewed', async () => { + const { fetch, calls } = fakeFetch(() => ({ status: 401, body: {} })); + const client = new LandingClient({ apiBase: 'http://app.test/api', token: 'fixed', fetch }); + + const response = await client.call(showPage, { page: 1 }); + + assert.equal(response.status, 401); + assert.equal(calls.length, 1); + assert.equal(calls[0].headers.Authorization, 'Bearer fixed'); +}); + +test('refuses an authenticated call without credentials', async () => { + const client = new LandingClient({ apiBase: 'http://app.test/api', fetch: async () => assert.fail('no request') }); + + await assert.rejects(client.call(showPage, { page: 1 }), /LANDING_TOKEN/); +}); + +test('refuses a call missing a path parameter', async () => { + const client = new LandingClient({ apiBase: 'http://app.test/api', token: 't', fetch: async () => assert.fail('no request') }); + + await assert.rejects(client.call(showPage, {}), /"page"/); +}); diff --git a/mcp/test/http.test.mjs b/mcp/test/http.test.mjs new file mode 100644 index 0000000..211c654 --- /dev/null +++ b/mcp/test/http.test.mjs @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { after, before, test } from 'node:test'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { Catalogue } from '../src/catalogue.mjs'; +import { parseAuthorization, startHttpServer } from '../src/http.mjs'; + +const spec = await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8'); + +/** + * Stands in for the application: the spec, the token endpoint, /v1/me and one + * public endpoint that reports whether it was sent the preview secret. + */ +const app = { logins: 0, requests: [] }; + +const appServer = createServer(async (req, res) => { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null; + + app.requests.push({ url: req.url, headers: req.headers }); + + const json = (status, payload) => { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + + if (req.url === '/openapi.yaml') return res.end(spec); + + if (req.url === '/api/v1/auth/token') { + app.logins++; + + return body.username === 'ada@example.com' && body.password === 'secret' + ? json(200, { access_token: 'ada-token', refresh_token: 'r', expires_at: new Date(Date.now() + 3_600_000).toISOString() }) + : json(422, { message: 'These credentials do not match our records.' }); + } + + if (req.url.startsWith('/api/v1/')) { + return ['Bearer ada-token', 'Bearer valid-token'].includes(req.headers.authorization) + ? json(200, { data: { email: 'ada@example.com' } }) + : json(401, { message: 'Unauthenticated.' }); + } + + if (req.url.startsWith('/api/public/v1/site')) { + return json(200, { data: { preview: req.headers['x-preview-token'] === 'drafts' } }); + } + + json(404, { message: 'Not found' }); +}); + +let mcpUrl; +let mcpServer; + +before(async () => { + appServer.listen(0, '127.0.0.1'); + await once(appServer, 'listening'); + + const appUrl = `http://127.0.0.1:${appServer.address().port}`; + + mcpServer = startHttpServer({ + catalogue: new Catalogue({ source: `${appUrl}/openapi.yaml`, baseUrl: appUrl }), + port: 0, + host: '127.0.0.1', + previewToken: 'drafts', + }); + await once(mcpServer, 'listening'); + + mcpUrl = new URL(`http://127.0.0.1:${mcpServer.address().port}/mcp`); +}); + +after(() => { + mcpServer.close(); + appServer.close(); +}); + +async function connect(authorization) { + const client = new Client({ name: 'test', version: '0' }); + const headers = authorization ? { Authorization: authorization } : {}; + + await client.connect(new StreamableHTTPClientTransport(mcpUrl, { requestInit: { headers } })); + + return client; +} + +const basic = (user, password) => `Basic ${Buffer.from(`${user}:${password}`).toString('base64')}`; + +test('an anonymous caller gets the public API, without the preview secret', async () => { + const client = await connect(); + const { tools } = await client.listTools(); + + assert.ok(tools.length > 0); + assert.ok(tools.every((tool) => tool.name.startsWith('public_'))); + + const result = await client.callTool({ name: 'public_site', arguments: {} }); + + assert.match(result.content[0].text, /"preview":false/); + await client.close(); +}); + +test('Basic credentials get both APIs, and sign in once across requests', async () => { + const before = app.logins; + + for (let i = 0; i < 3; i++) { + const client = await connect(basic('ada@example.com', 'secret')); + const { tools } = await client.listTools(); + + assert.ok(tools.some((tool) => tool.name === 'admin_pages_update')); + + const me = await client.callTool({ name: 'admin_me', arguments: {} }); + assert.equal(me.isError, false); + + const site = await client.callTool({ name: 'public_site', arguments: {} }); + assert.match(site.content[0].text, /"preview":true/); + + await client.close(); + } + + assert.equal(app.logins - before, 1); +}); + +test('wrong credentials are a 401, remembered rather than retried', async () => { + const before = app.logins; + + for (let i = 0; i < 2; i++) { + await assert.rejects(connect(basic('ada@example.com', 'wrong')), /401|Credentials refused/); + } + + assert.equal(app.logins - before, 1); +}); + +test('a bearer token is checked against the application', async () => { + const good = await connect('Bearer valid-token'); + assert.ok((await good.listTools()).tools.some((tool) => tool.name === 'admin_me')); + await good.close(); + + await assert.rejects(connect('Bearer revoked-token'), /401|refused/); +}); + +test('only POST /mcp is served, and /up answers without the application', async () => { + const base = `http://127.0.0.1:${mcpServer.address().port}`; + + assert.equal((await fetch(`${base}/up`)).status, 200); + assert.equal((await fetch(`${base}/mcp`)).status, 405); + assert.equal((await fetch(`${base}/elsewhere`)).status, 404); +}); + +test('parses Authorization headers', () => { + assert.deepEqual(parseAuthorization(basic('a@b.c', 'p:with:colons')), { username: 'a@b.c', password: 'p:with:colons' }); + assert.deepEqual(parseAuthorization('bearer abc'), { token: 'abc' }); + assert.equal(parseAuthorization(basic('', 'p')), null); + assert.equal(parseAuthorization('Digest abc'), null); +}); diff --git a/mcp/test/tools.test.mjs b/mcp/test/tools.test.mjs new file mode 100644 index 0000000..f096f9b --- /dev/null +++ b/mcp/test/tools.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; +import { parse } from 'yaml'; +import { buildTools } from '../src/tools.mjs'; + +// The document in this checkout: the one the application serves. +const spec = parse(await readFile(new URL('../../public/openapi.yaml', import.meta.url), 'utf8')); +const tools = buildTools(spec); +const byName = new Map(tools.map((tool) => [tool.name, tool])); + +test('names follow the Laravel routes', () => { + const expected = { + public_site: 'GET /public/v1/site', + public_good_first_issues: 'GET /public/v1/good-first-issues', + public_projects_index: 'GET /public/v1/projects', + public_projects_show: 'GET /public/v1/projects/{slug}', + public_clients: 'GET /public/v1/clients', + admin_me: 'GET /v1/me', + admin_pages_index: 'GET /v1/pages', + admin_pages_store: 'POST /v1/pages', + admin_pages_update: 'PATCH /v1/pages/{page}', + admin_pages_destroy: 'DELETE /v1/pages/{page}', + admin_pages_restore: 'POST /v1/pages/{page}/restore', + admin_roles_catalogue: 'GET /v1/roles/catalogue', + }; + + for (const [name, route] of Object.entries(expected)) { + const tool = byName.get(name); + assert.ok(tool, `missing ${name}`); + assert.equal(`${tool.operation.method} ${tool.operation.path}`, route); + } +}); + +test('names are valid MCP tool names', () => { + for (const { name } of tools) assert.match(name, /^[a-z0-9_]{1,64}$/); +}); + +test('leaves out what is not for an operator', () => { + const routes = tools.map(({ operation }) => `${operation.method} ${operation.path}`); + + assert.ok(!routes.includes('POST /v1/auth/token')); + assert.ok(!routes.some((route) => route.includes('/telemetry/'))); + assert.ok(!routes.includes('POST /public/v1/contact')); + assert.ok(!routes.includes('POST /public/v1/calls/{slug}/apply')); +}); + +test('every other documented operation is a tool', () => { + const documented = Object.values(spec.paths) + .flatMap((item) => ['get', 'post', 'put', 'patch', 'delete'].filter((m) => item[m])).length; + + // auth/token, two telemetry endpoints, contact and apply. + assert.equal(tools.length, documented - 5); +}); + +test('schemas are self-contained JSON Schema', () => { + for (const tool of tools) { + const json = JSON.stringify(tool.inputSchema); + + assert.ok(!json.includes('"$ref"'), `${tool.name} still has a $ref`); + assert.ok(!json.includes('"nullable"'), `${tool.name} still has nullable`); + } +}); + +test('path parameters are required, headers are not offered', () => { + const show = byName.get('public_projects_show'); + + assert.deepEqual(show.inputSchema.required, ['slug']); + assert.ok(!('X-Preview-Token' in show.inputSchema.properties)); +}); + +test('a create requires its fields, an update does not', () => { + assert.deepEqual(byName.get('admin_pages_store').inputSchema.properties.body.required, ['title', 'slug']); + assert.equal(byName.get('admin_pages_update').inputSchema.properties.body.required, undefined); +}); + +test('only the admin API is marked as needing a token', () => { + for (const tool of tools) { + assert.equal(tool.operation.requiresAuth, tool.name.startsWith('admin_'), tool.name); + } +});