UNPKG

rapidoc

Version:

RapiDoc - Open API spec viewer with built in console

383 lines (364 loc) • 14.4 kB
/** * Builds the MCP manifest: a plain JSON description of an API's tools that a runtime executes. * Pure, no dependencies; takes a raw OpenAPI 3.x / Swagger 2.0 object or the result of * `ProcessSpec` (`tags[].paths[]`). */ import { AUTH_TRANSPORT_HEADERS, deriveToolName, sanitizeDescription, toSnakeCase, } from "./common.js"; import { resolvePointer, toJsonSchema } from "./schema.js"; import { compileWorkflows } from "./workflows.js"; import { indexOperations, indexResolvedSpec } from "../arazzo/operations.js"; export { toJsonSchema }; export const MANIFEST_VERSION = 1; const METHODS = ["get", "put", "post", "delete", "options", "head", "patch", "trace", "query"]; const RESOURCE_PATH_HINTS = [ "/reference", "/currencies", "/countries", "/tiers", "/categories", "/status", "/schema", ]; const SECURITY_KEYS = [ "type", "description", "in", "name", "scheme", "bearerFormat", "openIdConnectUrl", ]; function isProcessedSpec(spec) { return ( "specLoadError" in spec || (Array.isArray(spec.tags) && spec.tags.some((t) => Array.isArray(t?.paths))) ); } /** * Follows a `#/...` $ref within `spec` (a few hops, for refs to refs); anything else is returned as is. * A raw spec keeps its parameters, request bodies and path items as $refs to components; a processed * one (`<rapi-doc>`'s resolvedSpec) is already dereferenced. */ function follow(node, spec) { for ( let hops = 0; hops < 10 && typeof node?.$ref === "string" && node.$ref.startsWith("#/"); hops++ ) { let target = spec; for (const segment of node.$ref.slice(2).split("/")) { const key = decodeURIComponent(segment).replace(/~1/g, "/").replace(/~0/g, "~"); target = target?.[key]; } if (!target || typeof target !== "object") return node; node = target; } return node; } /** Every operation with the fields the manifest needs, from either spec shape. */ function collectOperations(spec, processed) { const operations = []; if (processed) { for (const tag of spec.tags || []) { for (const p of tag.paths || []) { operations.push({ method: String(p.method || "get").toLowerCase(), path: p.path || "", operationId: p.rawOperation?.operationId || "", summary: p.summary || "", description: p.description || "", deprecated: p.deprecated === true, isWebhook: p.isWebhook === true, internal: p.xInternal === true || p.xHidden === true, parameters: Array.isArray(p.parameters) ? p.parameters : [], requestBody: p.requestBody || null, security: p.security, tag: tag.name || "", }); } } return operations; } const params = (list) => (Array.isArray(list) ? list.map((p) => follow(p, spec)) : []); for (const [path, rawItem] of Object.entries(spec.paths || {})) { const item = follow(rawItem, spec); if (!item || typeof item !== "object") continue; const common = params(item.parameters); for (const method of METHODS) { const op = item[method]; if (!op || typeof op !== "object") continue; const own = params(op.parameters); // Path-level parameters apply unless the operation redefines them (same name and location) const parameters = common .filter((c) => !own.some((o) => o?.name === c?.name && o?.in === c?.in)) .concat(own); operations.push({ method, path, operationId: op.operationId || "", summary: op.summary || "", description: op.description || "", deprecated: op.deprecated === true, isWebhook: false, internal: op["x-internal"] === true || op["x-hidden"] === true || item["x-internal"] === true || item["x-hidden"] === true, parameters, requestBody: follow(op.requestBody, spec) || null, security: op.security ?? spec.security, tag: Array.isArray(op.tags) ? op.tags[0] || "" : "", }); } } return operations; } /** The spec's security schemes as `{ id: { type, in, name, scheme, ... } }`, from either shape. */ function collectSecuritySchemes(spec, processed) { const out = {}; const add = (id, scheme) => { if (!scheme || typeof scheme !== "object" || !scheme.type) return; const entry = {}; for (const key of SECURITY_KEYS) if (scheme[key] !== undefined) entry[key] = scheme[key]; if (scheme.type === "oauth2" && scheme.flows && typeof scheme.flows === "object") { entry.flows = scheme.flows; } out[id] = entry; }; if (processed) { for (const s of spec.securitySchemes || []) { // The scheme <rapi-doc>'s api-key attributes inject is the page's, not the API's if (s?.securitySchemeId && s.securitySchemeId !== "_rapidoc_api_key") { add(s.securitySchemeId, s.type === "apiKey" ? s : { ...s, in: undefined, name: undefined }); } } } else { const schemes = spec.components?.securitySchemes || spec.securityDefinitions || {}; for (const [id, scheme] of Object.entries(schemes)) { add(id, scheme?.$ref ? resolvePointer(spec, scheme.$ref) : scheme); } } return out; } function baseUrlOf(spec, processed) { const server = spec.servers?.[0]; if (server?.url) { let url = String(server.url); for (const [name, v] of Object.entries(server.variables || {})) { url = url.split(`{${name}}`).join(String(v?.default ?? v?.enum?.[0] ?? "")); } return url; } if (!processed && spec.host) { return `${spec.schemes?.[0] || "https"}://${spec.host}${spec.basePath || ""}`; } return "http://localhost"; } /** A parameter's schema, from `schema`, `content` or Swagger 2.0's inline type keywords. */ function parameterSchema(param, root) { if (param.schema) return toJsonSchema(param.schema, root); if (param.content && typeof param.content === "object") { const first = Object.values(param.content)[0]; if (first?.schema) return toJsonSchema(first.schema, root); } const inline = {}; for (const key of [ "type", "format", "enum", "items", "default", "minimum", "maximum", "pattern", ]) { if (param[key] !== undefined) inline[key] = param[key]; } return inline.type ? toJsonSchema(inline, root) : { type: "string" }; } function bodyContent(requestBody) { const content = requestBody?.content; if (!content || typeof content !== "object") return null; const types = Object.keys(content); const pick = types.find((t) => t === "application/json") || types.find((t) => t.endsWith("+json")) || types.find((t) => t === "application/x-www-form-urlencoded") || types.find((t) => t === "multipart/form-data") || types[0]; return pick ? { contentType: pick, schema: content[pick]?.schema } : null; } /** * @param {object} spec Raw OpenAPI / Swagger object, or `ProcessSpec`'s result * @param {object} [options] * @param {string} [options.serverName] the MCP server's name; defaults to the API title * @param {object|string} [options.arazzo] a parsed Arazzo document (or JSON text) whose workflows * become tools; its first OpenAPI source description is this API * @param {(problem: { level: string, path: string, message: string }) => void} [options.onProblem] * receives one entry per workflow the runtime cannot run, which is left out * @param {string} [options.sourceName] the key of the API in `sources`: the Arazzo document's * first OpenAPI source name, else `api` * @param {Record<string, object>} [options.sources] the other OpenAPI documents the Arazzo * document's workflows use, by source description name; each becomes a manifest source and its * operations resolve for workflow steps * @param {boolean} [options.includeInternal] keep `x-internal` / `x-hidden` operations * @param {boolean} [options.includeDeprecated] keep deprecated operations * @param {boolean} [options.includeOperations] `false` leaves the operations out: a manifest of the * Arazzo document's workflow tools alone (the sources stay, the runtime needs them) * @returns {object} the manifest: `{ manifestVersion, server, sources, tools, resources, * workflows }`. A workflow tool has `workflow` (its id in `workflows`) instead of `operation`. Each tool has `name`, `description`, `inputSchema` (JSON Schema; parameters by * name plus `body`), `annotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`) and * `operation` (`source`, `method`, `path`, `params` [{ name, in, arg?, contentType? }], * `security`). */ export function buildMcpManifest(spec, options = {}) { const src = spec && typeof spec === "object" ? spec : {}; const processed = isProcessedSpec(src); const { includeInternal = false, includeDeprecated = false, includeOperations = true } = options; const info = src.info || {}; const title = info.title || "OpenAPI MCP Server"; const arazzo = typeof options.arazzo === "string" ? JSON.parse(options.arazzo) : options.arazzo; const firstSource = (arazzo?.sourceDescriptions || []).find( (s) => s?.name && (!s.type || s.type === "openapi"), ); const sourceName = options.sourceName || firstSource?.name || "api"; const securitySchemes = collectSecuritySchemes(src, processed); // Header, query and cookie parameters that carry a credential are the runtime's business const credentialParams = new Set( Object.values(securitySchemes) .filter((s) => s.type === "apiKey" && s.name) .map((s) => `${s.in || "header"}:${String(s.name).toLowerCase()}`), ); const tools = []; const resources = []; const usedNames = new Map(); for (const op of includeOperations ? collectOperations(src, processed) : []) { if (op.isWebhook) continue; if (op.internal && !includeInternal) continue; if (op.deprecated && !includeDeprecated) continue; const properties = {}; const required = []; const params = []; for (const p of op.parameters) { if (!p?.name) continue; const where = p.in || "query"; const lower = String(p.name).toLowerCase(); if (where === "header" && AUTH_TRANSPORT_HEADERS.has(lower)) continue; if (credentialParams.has(`${where}:${lower}`)) continue; if (where === "body") { // Swagger 2.0 body parameter const schema = parameterSchema(p, src); if (p.description) schema.description = sanitizeDescription(p.description); properties.body = schema; if (p.required) required.push("body"); params.push({ name: "body", in: "body", contentType: "application/json" }); continue; } if (where === "formData") continue; let arg = p.name; if (arg in properties) arg = `${p.name}_${where}`; const schema = parameterSchema(p, src); if (p.description) schema.description = sanitizeDescription(p.description); properties[arg] = schema; if (p.required || where === "path") required.push(arg); params.push(arg === p.name ? { name: p.name, in: where } : { name: p.name, in: where, arg }); } const body = bodyContent(op.requestBody); if (body) { const schema = body.schema ? toJsonSchema(body.schema, src) : { type: "object" }; if (op.requestBody.description) { schema.description = sanitizeDescription(op.requestBody.description); } properties.body = schema; if (op.requestBody.required) required.push("body"); params.push({ name: "body", in: "body", contentType: body.contentType }); } const inputSchema = { type: "object", properties }; if (required.length) inputSchema.required = required; let name = toSnakeCase(op.operationId || deriveToolName(op.method, op.path)); const seen = usedNames.get(name) || 0; usedNames.set(name, seen + 1); if (seen) name = `${name}_${seen + 1}`; const description = sanitizeDescription( op.summary || op.description || `${op.method.toUpperCase()} ${op.path}`, ); const operation = { source: sourceName, method: op.method.toUpperCase(), path: op.path, params, security: Array.isArray(op.security) ? op.security.filter((s) => s && typeof s === "object") : [], }; const isGet = op.method === "get"; const isResource = isGet && !required.length && RESOURCE_PATH_HINTS.some((hint) => op.path.includes(hint)); if (isResource) { resources.push({ name, uri: `reference://${name.replace(/^get_/, "").replace(/_/g, "-")}`, description, mimeType: "application/json", operation, }); continue; } tools.push({ name, description, inputSchema, annotations: { readOnlyHint: isGet, // POST adds; PUT, PATCH and DELETE change or remove what is there destructiveHint: ["put", "patch", "delete"].includes(op.method), idempotentHint: ["get", "put", "delete"].includes(op.method), }, operation, }); } const workflows = {}; const sources = { [sourceName]: { baseUrl: baseUrlOf(src, processed), securitySchemes } }; if (arazzo && typeof arazzo === "object") { const indexes = new Map([ [sourceName, processed ? indexResolvedSpec(src) : indexOperations(src)], ]); for (const [name, doc] of Object.entries(options.sources || {})) { if (name === sourceName || !doc || typeof doc !== "object") continue; const docProcessed = isProcessedSpec(doc); indexes.set(name, docProcessed ? indexResolvedSpec(doc) : indexOperations(doc)); sources[name] = { baseUrl: baseUrlOf(doc, docProcessed), securitySchemes: collectSecuritySchemes(doc, docProcessed), }; } const compiled = compileWorkflows(arazzo, { sources: indexes, onProblem: options.onProblem, }); for (const tool of compiled.tools) { const seen = usedNames.get(tool.name) || 0; usedNames.set(tool.name, seen + 1); tools.push(seen ? { ...tool, name: `${tool.name}_${seen + 1}` } : tool); } Object.assign(workflows, compiled.workflows); } return { manifestVersion: MANIFEST_VERSION, server: { name: toSnakeCase(options.serverName || title), version: info.version || "1.0.0", description: sanitizeDescription(info.description || ""), }, sources, tools, resources, workflows, }; }