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

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