Files
landing-api/mcp/src/tools.mjs
Josep Piguillem ac546eba2b
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
Manage technologies through the API, with SVG logos
/api/v1/technologies is a full CRUD with the panel's own rules: name and
slug are plain strings, the prose is translatable, `radar_ring: null`
takes one off the radar, and TechnologyPolicy still refuses to delete
one a project, member or call uses. The MCP server picks the tools up
from openapi.yaml on its own.

A logo is an SVG, so POST /v1/media now accepts one — sanitized with
enshrined/svg-sanitize (scripts, event handlers and remote references
removed), and served by Caddy under a Content-Security-Policy that
would stop a script regardless. `for` (pages or technologies) says what
an upload is for, which picks the directory the panel uses for it and
the permission it takes.

Creating a technology without a category answered 500: the column's
default never reached the model, so the response read a null enum. The
controller now states the default, as the panel does.

Beside a file, the MCP upload tool no longer demands the JSON body's
`url`: the body then carries the upload's other fields, such as `for`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 19:49:00 +02:00

350 lines
12 KiB
JavaScript

/**
* 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 {string[]} files Multipart fields filled from a local file, read from `<name>_path`.
* @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.
* @param {object} [options]
* @param {boolean} [options.localFiles] Offer uploads from a path on this
* machine. Only true over stdio, where this machine is the caller's own; a
* deployed server would be reading its own disk on a stranger's say-so.
* @returns {Tool[]}
*/
export function buildTools(spec, { localFiles = false } = {}) {
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;
const tool = buildTool(spec, paths, path, method, item, op, localFiles);
if (tool) tools.push(tool);
}
}
// 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, localFiles) {
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;
const multipartSchema = op.requestBody?.content?.['multipart/form-data']?.schema;
// A file in a multipart body cannot travel as JSON — as base64 an ordinary
// photo costs hundreds of thousands of tokens — so the model names a path
// and the server reads it. Only where that path is on the caller's machine.
const files = localFiles && multipartSchema
? Object.entries(toJsonSchema(spec, multipartSchema).properties ?? {})
.filter(([, property]) => property.format === 'binary')
.map(([name]) => name)
: [];
// Multipart only, and no way to send a file from here: nothing to offer.
if (!bodySchema && multipartSchema && files.length === 0) return null;
for (const field of files) {
const name = `${field}_path`;
if (name in properties) {
throw new Error(`${method.toUpperCase()} ${path} has a parameter named "${name}", which the file upload needs`);
}
properties[name] = {
type: 'string',
description: `Absolute path to a file on this machine, uploaded as \`${field}\`. Read by the MCP server; use instead of \`body\`.`,
};
}
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;
// Beside a file, the body carries the upload's other fields (`for`, say)
// rather than the JSON alternative, whose own requirements — a `url` —
// would then be wrong.
if (files.length > 0) delete body.required;
properties.body = {
...body,
description: files.length > 0
? 'JSON request body; with a file, its other fields go here too.'
: 'JSON request body.',
};
// With a file to send instead, the JSON body is the other way in rather
// than a requirement; the API says which one it missed if neither comes.
if (op.requestBody.required && files.length === 0) 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),
files,
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' }] };
}