/** * 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 `_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' }] }; }