/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>
350 lines
12 KiB
JavaScript
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' }] };
|
|
}
|