Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-name>/spec` | The full dereferenced OpenAPI document, JSON |
| `openapi://<spec-name>/tools` | `[{ name, method, path, description, tags }]` for every compiled tool, filtered by the active tool policy |

`<spec-name>` is the `--spec name=` value or the spec file's basename (`openapi://sample-openapi/spec` for `sample-openapi.yaml`).

## Transports

| Transport | Flag | Endpoints |
Expand Down
83 changes: 80 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,9 +57,19 @@ interface CompiledValidators {
responsesByStatus: Map<string, Map<string, Validator>>;
}

interface SpecResource {
name: string;
path: string;
title: string;
version: string;
doc: Record<string, unknown>;
toolNames: string[];
}

interface RuntimeState {
operations: Map<string, OperationModel>;
validators: CompiledValidators;
specDocs: SpecResource[];
}

interface SpecRef {
Expand Down Expand Up @@ -177,9 +189,54 @@ async function main(): Promise<void> {
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))
Expand Down Expand Up @@ -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`);
Expand All @@ -577,6 +635,7 @@ function deriveSpecName(spec: SpecRef, separator?: string): string {
async function loadRuntimeState(specs: SpecRef[], serverUrl: string | undefined, cachePath: string, compile: CompileOptions): Promise<RuntimeState> {
const sep = compile.toolNameSeparator ?? "_";
const merged = new Map<string, OperationModel>();
const specDocs: SpecResource[] = [];

for (const spec of specs) {
const specPath = resolve(spec.path);
Expand All @@ -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
Expand All @@ -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<string, unknown>).info) ? ((doc as Record<string, unknown>).info as Record<string, unknown>) : {};
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<string, OperationModel>): CompiledValidators {
Expand Down
95 changes: 95 additions & 0 deletions test/resources.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>): Promise<void> {
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<string, unknown>;
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_")));
}
);
});
Loading