Add an MCP server for the API, deployable next to the application
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:
198
mcp/src/client.mjs
Normal file
198
mcp/src/client.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user