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>
This commit is contained in:
2026-09-27 21:58:14 +02:00
parent 5ca084bd4a
commit 748b485c9b
20 changed files with 2862 additions and 2 deletions

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