Compare commits

...

2 Commits

Author SHA1 Message Date
748b485c9b Add an MCP server for the API, deployable next to the application
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
mcp/ exposes both APIs as MCP tools, one per operation in openapi.yaml,
named after the Laravel route each lands on (public_projects_index,
admin_pages_update, ...). Nothing is written by hand: the tools are
derived from the document the target application serves, which the
contract tests already hold to the truth.

Two transports over one core:

- stdio (`node mcp/server.mjs`), registered in .mcp.json, signing in
  with credentials from the environment and refreshing the token.
- Streamable HTTP (`--http`), stateless, deployed as the landing-mcp
  service. It holds no credentials: each request brings its caller's
  Pireon Basic credentials or bearer token, so the API applies that
  person's permissions. Sessions live in memory keyed by a hash,
  refused credentials are remembered for a minute, and the spec is
  reloaded every minute so new endpoints appear without a redeploy.

Left out on purpose: the token exchange, telemetry, and the contact and
apply forms, which notify real people. The preview secret is never sent
for anonymous callers, and is unset in the deployment by default.

A CI job runs the Node tests, including the HTTP mode end to end
against a fake application.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:14 +02:00
5ca084bd4a Throttle the token endpoint per account and address, not per address
POST /api/v1/auth/token was limited to 6 requests a minute per IP. The
deployed MCP server signs every user in from a single container, so
under a per-IP budget one person's mistyped password, or a stranger's
guesses, would lock every other user out of it.

The named `auth-token` limiter keys on the lower-cased username (or the
refresh token) plus the address, still 6 a minute, with a 30-a-minute
ceiling per address to bound how many accounts one client can try.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:58:13 +02:00
24 changed files with 2913 additions and 5 deletions

View File

@@ -20,3 +20,6 @@ database/*.sqlite-journal
.phpunit.result.cache .phpunit.result.cache
docs/*.pdf docs/*.pdf
public/storage public/storage
# Runs on a developer machine, next to Claude Code, never in the image.
mcp

View File

@@ -123,3 +123,18 @@ MAIL_USERNAME=
MAIL_PASSWORD= MAIL_PASSWORD=
MAIL_FROM_ADDRESS="admin@example.org" MAIL_FROM_ADDRESS="admin@example.org"
MAIL_FROM_NAME="${APP_NAME}" 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=

View File

@@ -79,3 +79,24 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: docker compose -f docker-compose.test.yml run --rm test - 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

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ _ide_helper.php
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
/mcp/node_modules

15
.mcp.json Normal file
View File

@@ -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:-}"
}
}
}
}

View File

@@ -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 viajan como comentarios: la mitad de lo que sabe el `openapi.yaml` está en su
prosa, y un tipo a secas no la conserva. 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://<dominio>/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 ## Comandos
```bash ```bash
@@ -1458,7 +1551,7 @@ párrafo aparte sin relación programática con él.
## Estructura ## 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 docker-compose.test.yml la suite contra Postgres, desechable
Dockerfile imagen única para los tres contenedores PHP Dockerfile imagen única para los tres contenedores PHP
docker/ Caddyfile, php.ini y entrypoint 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/media.php disco, anchos y formatos de imagen
config/git.php proveedores de git y etiquetas de issues config/git.php proveedores de git y etiquetas de issues
public/openapi.yaml especificación de ambas APIs public/openapi.yaml especificación de ambas APIs
mcp/ servidor MCP (stdio y HTTP) generado desde la anterior
``` ```
## Despliegue con Docker ## Despliegue con Docker

View File

@@ -54,6 +54,21 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120) RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)
->by($request->user()?->id ?: $request->ip())); ->by($request->user()?->id ?: $request->ip()));
// Backs POST /api/v1/auth/token. Budgeted per account and address
// rather than per address alone, because the deployed MCP server
// (mcp/) signs everyone in from one container: per IP, one person's
// wrong password — or a stranger's guesses — would lock every other
// user out of it. The per-address ceiling still bounds how many
// accounts a single client can try.
RateLimiter::for('auth-token', fn (Request $request) => [
Limit::perMinute(6)->by('auth-token:'.sha1(
Str::lower((string) ($request->input('username') ?: $request->input('refresh_token')))
.'|'.$request->ip()
)),
Limit::perMinute(30)->by('auth-token-ip:'.$request->ip()),
]);
// The public API is a different problem. The public frontend renders // The public API is a different problem. The public frontend renders
// server-side, so every visitor's page view arrives from the same IP: // server-side, so every visitor's page view arrives from the same IP:
// a per-IP budget sized for one person would throttle the entire // a per-IP budget sized for one person would throttle the entire

View File

@@ -1,7 +1,7 @@
############################################################################### ###############################################################################
# Production stack. # 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 # 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 one less thing to run, back up and forget to secure. Swapping it in later
# is three environment variables. # is three environment variables.
@@ -210,6 +210,45 @@ services:
condition: service_healthy condition: service_healthy
stop_grace_period: 30s 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://<that domain>/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: postgres:
image: postgres:17-alpine image: postgres:17-alpine
restart: unless-stopped restart: unless-stopped

4
mcp/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules
test
Dockerfile
.dockerignore

26
mcp/Dockerfile Normal file
View File

@@ -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"]

1222
mcp/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
mcp/package.json Normal file
View File

@@ -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"
}
}

94
mcp/server.mjs Normal file
View File

@@ -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)'}`);
}

78
mcp/src/catalogue.mjs Normal file
View File

@@ -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();
}
}

198
mcp/src/client.mjs Normal file
View File

@@ -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<string, unknown>} 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;
}
}

288
mcp/src/http.mjs Normal file
View File

@@ -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 <token> 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<string, {client: Promise<LandingClient>, lastUsed: number, expiresAt: number}>} */
this.entries = new Map();
/** @type {Map<string, number>} */
this.rejected = new Map();
this.anonymous = null;
}
/** @returns {Promise<LandingClient>} */
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 });
}

52
mcp/src/mcp.mjs Normal file
View File

@@ -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;
}

303
mcp/src/tools.mjs Normal file
View File

@@ -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' }] };
}

151
mcp/test/client.test.mjs Normal file
View File

@@ -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"/);
});

155
mcp/test/http.test.mjs Normal file
View File

@@ -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);
});

81
mcp/test/tools.test.mjs Normal file
View File

@@ -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);
}
});

View File

@@ -909,8 +909,9 @@ paths:
description: | description: |
Proxies the identity server's `password` and `refresh_token` grants so Proxies the identity server's `password` and `refresh_token` grants so
first-party clients need not embed the OAuth server's URL or secret. first-party clients need not embed the OAuth server's URL or secret.
Rate limited to 6 requests per minute. Browser clients should use Rate limited to 6 requests per minute per account and address, and 30
Authorization Code + PKCE instead. per address overall. Browser clients should use Authorization Code +
PKCE instead.
security: [] security: []
requestBody: requestBody:
required: true required: true

View File

@@ -152,7 +152,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
// Convenience endpoint for first-party clients: exchanges credentials for // Convenience endpoint for first-party clients: exchanges credentials for
// a token upstream so they do not need to know the identity server's URL. // a token upstream so they do not need to know the identity server's URL.
Route::post('auth/token', [TokenController::class, 'store']) Route::post('auth/token', [TokenController::class, 'store'])
->middleware('throttle:6,1') ->middleware('throttle:auth-token')
->name('auth.token'); ->name('auth.token');
Route::middleware(['auth:api', 'throttle:api'])->group(function () { Route::middleware(['auth:api', 'throttle:api'])->group(function () {

View File

@@ -103,6 +103,38 @@ class TokenEndpointTest extends TestCase
->assertJsonValidationErrors('grant_type'); ->assertJsonValidationErrors('grant_type');
} }
#[Test]
public function one_account_exhausting_its_budget_does_not_lock_out_another_on_the_same_address(): void
{
// Everyone the MCP server signs in arrives from its one container.
// Registered first so it wins over fakePireon()'s token stub, which
// would otherwise accept every password.
Http::fake(["{$this->pireonServer}/oauth/token" => fn ($request) => $request['username'] === 'ada@example.com'
? Http::response(['access_token' => 'fake-access-token', 'token_type' => 'Bearer', 'expires_in' => 3600])
: Http::response(['error' => 'invalid_grant'], 400)]);
$this->fakePireon();
foreach (range(1, 6) as $attempt) {
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'mallory@example.com',
'password' => "guess-{$attempt}",
])->assertStatus(422);
}
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'MALLORY@example.com',
'password' => 'guess-7',
])->assertTooManyRequests();
$this->postJson('/api/v1/auth/token', [
'grant_type' => 'password',
'username' => 'ada@example.com',
'password' => 'secret',
])->assertOk();
}
#[Test] #[Test]
public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void public function repeated_calls_with_the_same_token_hit_the_identity_server_once(): void
{ {