diff --git a/README.md b/README.md index 64c0cbb..4a93656 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,17 @@ mcp-openapi --spec github=./github.yaml --spec linear=./linear.yaml - `tools/list` is cursor-paginated at 50 tools per page and emits `listChanged` when `--watch-spec` reloads the spec. - `x-mcp-hidden: true` on an operation removes it. `x-mcp-description` overrides the tool description, then `--descriptions` file entries, then `summary`/`description`. +## MCP resources + +The server exposes two read-only resources per loaded spec, so clients can introspect the API without extra tooling: + +| URI | Content | +|---|---| +| `openapi:///spec` | The full dereferenced OpenAPI document, JSON | +| `openapi:///tools` | `[{ name, method, path, description, tags }]` for every compiled tool, filtered by the active tool policy | + +`` is the `--spec name=` value or the spec file's basename (`openapi://sample-openapi/spec` for `sample-openapi.yaml`). + ## Transports | Transport | Flag | Endpoints | diff --git a/src/server.ts b/src/server.ts index 361855e..fe96d7a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,8 +25,10 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ import { CallToolRequestSchema, ErrorCode, + ListResourcesRequestSchema, ListToolsRequestSchema, McpError, + ReadResourceRequestSchema, type LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; import { executeOperation } from "./http.js"; @@ -55,9 +57,19 @@ interface CompiledValidators { responsesByStatus: Map>; } +interface SpecResource { + name: string; + path: string; + title: string; + version: string; + doc: Record; + toolNames: string[]; +} + interface RuntimeState { operations: Map; validators: CompiledValidators; + specDocs: SpecResource[]; } interface SpecRef { @@ -177,9 +189,54 @@ async function main(): Promise { function createMcpServer(state: RuntimeState, cli: CliOptions): Server { const mcpServer = new Server( { name: "mcp-openapi", version: PKG_VERSION }, - { capabilities: { tools: { listChanged: true }, logging: {} } } + { capabilities: { tools: { listChanged: true }, resources: {}, logging: {} } } ); + mcpServer.setRequestHandler(ListResourcesRequestSchema, async () => ({ + resources: state.specDocs.flatMap((spec) => [ + { + uri: `openapi://${spec.name}/spec`, + name: `${spec.title} ${spec.version} OpenAPI document`, + description: `Dereferenced OpenAPI document for ${spec.title} (${spec.path})`, + mimeType: "application/json" + }, + { + uri: `openapi://${spec.name}/tools`, + name: `${spec.title} ${spec.version} tool index`, + description: `Tool name, method, path, and description for every tool compiled from ${spec.title}`, + mimeType: "application/json" + } + ]) + })); + + mcpServer.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + for (const spec of state.specDocs) { + if (uri === `openapi://${spec.name}/spec`) { + return { + contents: [{ uri, mimeType: "application/json", text: JSON.stringify(spec.doc, null, 2) }] + }; + } + if (uri === `openapi://${spec.name}/tools`) { + const index = spec.toolNames + .map((name) => state.operations.get(name)) + .filter((op): op is OperationModel => Boolean(op)) + .filter((op) => isToolAllowed(op, cli.runtime)) + .map((op) => ({ + name: op.operationId, + method: op.method, + path: op.pathTemplate, + description: op.toolDescription, + ...(op.tags ? { tags: op.tags } : {}) + })); + return { + contents: [{ uri, mimeType: "application/json", text: JSON.stringify(index, null, 2) }] + }; + } + } + throw new McpError(ErrorCode.InvalidParams, `Unknown resource: ${uri}`); + }); + mcpServer.setRequestHandler(ListToolsRequestSchema, async (request) => { const sortedTools = [...state.operations.values()] .filter((op) => isToolAllowed(op, cli.runtime)) @@ -554,6 +611,7 @@ function wireSpecWatcher(specs: SpecRef[], cli: CliOptions, state: RuntimeState, const next = await loadRuntimeState(specs, cli.serverUrl, cli.cachePath, cli.compile); state.operations = next.operations; state.validators = next.validators; + state.specDocs = next.specDocs; await onReload(); } catch (error) { process.stderr.write(`Spec reload failed: ${error instanceof Error ? error.message : String(error)}\n`); @@ -577,6 +635,7 @@ function deriveSpecName(spec: SpecRef, separator?: string): string { async function loadRuntimeState(specs: SpecRef[], serverUrl: string | undefined, cachePath: string, compile: CompileOptions): Promise { const sep = compile.toolNameSeparator ?? "_"; const merged = new Map(); + const specDocs: SpecResource[] = []; for (const spec of specs) { const specPath = resolve(spec.path); @@ -596,6 +655,7 @@ async function loadRuntimeState(specs: SpecRef[], serverUrl: string | undefined, const specCachePath = specs.length === 1 ? cachePath : `${cachePath}.${deriveSpecName(spec, sep)}`; const operations = await compileDocumentWithCache(doc, specPath, serverUrl, specCachePath, compile); + const toolNames: string[] = []; for (const operation of operations.values()) { const baseName = specs.length === 1 @@ -608,11 +668,28 @@ async function loadRuntimeState(specs: SpecRef[], serverUrl: string | undefined, toolName = normalizeToolName(`${baseName}${sep}${suffix}`, sep); } merged.set(toolName, { ...operation, operationId: toolName }); - } + toolNames.push(toolName); + } + + const info = isObject((doc as Record).info) ? ((doc as Record).info as Record) : {}; + let specName = deriveSpecName(spec, sep); + let nameSuffix = 1; + while (specDocs.some((existing) => existing.name === specName)) { + nameSuffix += 1; + specName = `${deriveSpecName(spec, sep)}${sep}${nameSuffix}`; + } + specDocs.push({ + name: specName, + path: spec.path, + title: typeof info.title === "string" ? info.title : specName, + version: typeof info.version === "string" ? info.version : "0.0.0", + doc, + toolNames + }); } const validators = buildValidators(merged); - return { operations: merged, validators }; + return { operations: merged, validators, specDocs }; } function buildValidators(operations: Map): CompiledValidators { diff --git a/test/resources.test.ts b/test/resources.test.ts new file mode 100644 index 0000000..c7ffa3a --- /dev/null +++ b/test/resources.test.ts @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { ListResourcesResultSchema, ReadResourceResultSchema } from "@modelcontextprotocol/sdk/types.js"; + +async function withStdioClient(args: string[], fn: (client: Client) => Promise): Promise { + const client = new Client({ name: "resources-test", version: "0.1.0" }, { capabilities: {} }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: ["dist/server.js", ...args], + cwd: process.cwd(), + stderr: "pipe" + }); + + try { + await client.connect(transport); + await fn(client); + } finally { + await transport.close(); + } +} + +test("resources/list exposes spec document and tool index per spec", async () => { + await withStdioClient(["--spec", "test/fixtures/sample-openapi.yaml"], async (client) => { + const result = await client.request({ method: "resources/list", params: {} }, ListResourcesResultSchema); + const uris = result.resources.map((r) => r.uri).sort(); + assert.deepEqual(uris, ["openapi://sample-openapi/spec", "openapi://sample-openapi/tools"]); + assert.ok(result.resources.every((r) => r.mimeType === "application/json")); + }); +}); + +test("resources/read returns the dereferenced OpenAPI document", async () => { + await withStdioClient(["--spec", "test/fixtures/sample-openapi.yaml"], async (client) => { + const result = await client.request( + { method: "resources/read", params: { uri: "openapi://sample-openapi/spec" } }, + ReadResourceResultSchema + ); + const doc = JSON.parse(String(result.contents[0]?.text)) as Record; + assert.equal(typeof doc.openapi, "string"); + assert.ok(doc.paths && typeof doc.paths === "object"); + }); +}); + +test("resources/read tool index lists compiled tools and respects deny policy", async () => { + await withStdioClient( + ["--spec", "test/fixtures/sample-openapi.yaml", "--deny-tools", "postEcho"], + async (client) => { + const result = await client.request( + { method: "resources/read", params: { uri: "openapi://sample-openapi/tools" } }, + ReadResourceResultSchema + ); + const index = JSON.parse(String(result.contents[0]?.text)) as Array<{ name: string; method: string; path: string }>; + const names = index.map((entry) => entry.name); + assert.ok(names.includes("getHealth")); + assert.ok(!names.includes("postEcho"), `deny-listed tool leaked into index: ${names.join(",")}`); + const health = index.find((entry) => entry.name === "getHealth"); + assert.equal(health?.method, "GET"); + assert.equal(health?.path, "/health"); + } + ); +}); + +test("resources/read rejects unknown URIs", async () => { + await withStdioClient(["--spec", "test/fixtures/sample-openapi.yaml"], async (client) => { + await assert.rejects( + client.request({ method: "resources/read", params: { uri: "openapi://nope/spec" } }, ReadResourceResultSchema), + /Unknown resource/ + ); + }); +}); + +test("multi-spec servers expose resources per spec", async () => { + await withStdioClient( + ["--spec", "alpha=test/fixtures/sample-openapi.yaml", "--spec", "beta=test/fixtures/sample-openapi.yaml"], + async (client) => { + const result = await client.request({ method: "resources/list", params: {} }, ListResourcesResultSchema); + const uris = result.resources.map((r) => r.uri).sort(); + assert.deepEqual(uris, [ + "openapi://alpha/spec", + "openapi://alpha/tools", + "openapi://beta/spec", + "openapi://beta/tools" + ]); + + const betaTools = await client.request( + { method: "resources/read", params: { uri: "openapi://beta/tools" } }, + ReadResourceResultSchema + ); + const index = JSON.parse(String(betaTools.contents[0]?.text)) as Array<{ name: string }>; + assert.ok(index.some((entry) => entry.name === "beta_getHealth")); + assert.ok(index.every((entry) => entry.name.startsWith("beta_"))); + } + ); +});