@suluk/mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +35 -0
- package/src/app.ts +72 -0
- package/src/exec.ts +43 -0
- package/src/index.ts +12 -0
- package/src/protocol.ts +76 -0
- package/src/tools.ts +135 -0
- package/test/mcp.test.ts +162 -0
- package/tsconfig.json +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@suluk/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Project ONE OpenAPI v4 document into a Model Context Protocol (MCP) server: each operation becomes an MCP tool (read-only by default), served over the Streamable-HTTP JSON-RPC transport as a Hono-mountable app. Same contract that drives the API/SDK/docs/admin/panel now drives an agent-callable surface — zero hand-written tool schemas. CANDIDATE tooling — NOT official OAS.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"license": "Apache-2.0",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/MahmoodKhalil57/suluk.git",
|
|
12
|
+
"directory": "tooling/ts/packages/mcp"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/MahmoodKhalil57/suluk#readme",
|
|
15
|
+
"bugs": "https://github.com/MahmoodKhalil57/suluk/issues",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@suluk/core": "^0.1.7"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"hono": "*"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/bun": "latest",
|
|
29
|
+
"hono": "*"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "bun test",
|
|
33
|
+
"typecheck": "tsc --noEmit -p ."
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/app.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app.ts — mount a contract-projected MCP server at `basePath` (default `/mcp`) over the Streamable-HTTP transport.
|
|
3
|
+
* POST carries JSON-RPC (single message or a batch); we always answer with `application/json` (this server initiates
|
|
4
|
+
* no server→client stream, so GET returns 405, which is conformant). Tools are projected from the document on each
|
|
5
|
+
* request, so a per-request (per-role) `document` function yields a per-role tool set for free. Read-only by default.
|
|
6
|
+
*/
|
|
7
|
+
import { Hono, type Context } from "hono";
|
|
8
|
+
import { bodyLimit } from "hono/body-limit";
|
|
9
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
10
|
+
import { toolsFrom, type ToolsOptions, type McpOp } from "./tools";
|
|
11
|
+
import { handleRpc, type RpcRequest } from "./protocol";
|
|
12
|
+
import { originExec } from "./exec";
|
|
13
|
+
|
|
14
|
+
/** Max JSON-RPC request body. MCP messages are tiny; this bounds a hostile oversized POST before it is parsed. */
|
|
15
|
+
const MAX_BODY = 256 * 1024;
|
|
16
|
+
|
|
17
|
+
export interface McpOptions extends ToolsOptions {
|
|
18
|
+
/** The v4 document — a value, or a per-request function (e.g. return projectDocument(doc, roleOf(c))). */
|
|
19
|
+
document: OpenAPIv4Document | ((c: Context) => OpenAPIv4Document | Promise<OpenAPIv4Document>);
|
|
20
|
+
basePath?: string;
|
|
21
|
+
/** Advertised server identity. */
|
|
22
|
+
name?: string;
|
|
23
|
+
version?: string;
|
|
24
|
+
/** Free-text guidance surfaced to the model on `initialize`. */
|
|
25
|
+
instructions?: string;
|
|
26
|
+
/** Gate the whole endpoint — return true to allow. Default: open (read-only catalog browsing). */
|
|
27
|
+
authorize?: (c: Context) => boolean | Promise<boolean>;
|
|
28
|
+
/** Override how a tool call is executed (default: same-origin fetch via {@link originExec}). */
|
|
29
|
+
exec?: (c: Context, op: McpOp, args: Record<string, unknown>) => Promise<unknown>;
|
|
30
|
+
/** Send permissive CORS so browser-based MCP clients can reach a public read-only server (default: true). */
|
|
31
|
+
cors?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CORS = { "access-control-allow-origin": "*", "access-control-allow-methods": "POST, OPTIONS", "access-control-allow-headers": "content-type, mcp-protocol-version, mcp-session-id, authorization" };
|
|
35
|
+
|
|
36
|
+
export function mcpApp(opts: McpOptions): Hono {
|
|
37
|
+
const base = (opts.basePath ?? "/mcp").replace(/\/$/, "");
|
|
38
|
+
const info = { name: opts.name ?? "suluk-mcp", version: opts.version ?? "0.1.0" };
|
|
39
|
+
const authorize = opts.authorize ?? (() => true);
|
|
40
|
+
const cors = opts.cors !== false ? CORS : {};
|
|
41
|
+
const app = new Hono();
|
|
42
|
+
|
|
43
|
+
async function docFor(c: Context): Promise<OpenAPIv4Document> {
|
|
44
|
+
return typeof opts.document === "function" ? await opts.document(c) : opts.document;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
app.options(base, (c) => c.body(null, 204, cors));
|
|
48
|
+
|
|
49
|
+
app.get(base, (c) => c.json({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "This MCP endpoint does not offer a server→client stream; POST JSON-RPC instead." } }, 405, { ...cors, allow: "POST, OPTIONS" }));
|
|
50
|
+
|
|
51
|
+
const tooBig = (c: Context) => c.json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Request body too large" } }, 413, cors);
|
|
52
|
+
app.post(base, bodyLimit({ maxSize: MAX_BODY, onError: tooBig }), async (c) => {
|
|
53
|
+
if (!(await authorize(c))) return c.json({ jsonrpc: "2.0", id: null, error: { code: -32001, message: "Unauthorized" } }, 401, cors);
|
|
54
|
+
|
|
55
|
+
let payload: unknown;
|
|
56
|
+
try { payload = await c.req.json(); } catch { return c.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, 400, cors); }
|
|
57
|
+
|
|
58
|
+
// MCP 2025-06-18 REMOVED JSON-RPC batching (one message per POST). Reject arrays up front — this also closes the
|
|
59
|
+
// batch→N-subrequest self-amplification vector (each tools/call would otherwise fan out into an origin fetch).
|
|
60
|
+
if (Array.isArray(payload)) return c.json({ jsonrpc: "2.0", id: null, error: { code: -32600, message: "Invalid Request: JSON-RPC batching was removed in MCP 2025-06-18 — send one message per request" } }, 400, cors);
|
|
61
|
+
|
|
62
|
+
const doc = await docFor(c);
|
|
63
|
+
const tools = toolsFrom(doc, opts);
|
|
64
|
+
const exec = opts.exec ? (op: McpOp, args: Record<string, unknown>) => opts.exec!(c, op, args) : (op: McpOp, args: Record<string, unknown>) => originExec(c, op, args);
|
|
65
|
+
|
|
66
|
+
const response = await handleRpc(payload as RpcRequest, { tools, info, exec, protocolVersion: undefined, instructions: opts.instructions });
|
|
67
|
+
if (response === null) return c.body(null, 202, cors); // a notification → accepted, no body
|
|
68
|
+
return c.json(response, 200, cors);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return app;
|
|
72
|
+
}
|
package/src/exec.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* exec.ts — the default tool executor: turn an `McpOp` + arguments back into an HTTP request against the SAME
|
|
3
|
+
* origin and return the parsed result. SSRF-safe by construction — the origin is fixed to the worker's own host,
|
|
4
|
+
* argument values are only ever `encodeURIComponent`'d into the path template or set via `searchParams` (which
|
|
5
|
+
* percent-encodes), so a caller can influence the path/query VALUES but never the scheme, host, or route.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from "hono";
|
|
8
|
+
import type { McpOp } from "./tools";
|
|
9
|
+
|
|
10
|
+
/** Build the same-origin Request for an operation call. `origin` is trusted; `args` values are caller-supplied. */
|
|
11
|
+
export function buildRequest(op: McpOp, args: Record<string, unknown>, origin: string, headers: Record<string, string> = {}): Request {
|
|
12
|
+
let path = op.path;
|
|
13
|
+
for (const p of op.pathParams) {
|
|
14
|
+
const v = args[p];
|
|
15
|
+
path = path.replace(new RegExp(`\\{${p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\}`, "g"), encodeURIComponent(String(v ?? "")));
|
|
16
|
+
}
|
|
17
|
+
const url = new URL(path, origin);
|
|
18
|
+
for (const q of op.queryParams) {
|
|
19
|
+
const v = args[q];
|
|
20
|
+
if (v !== undefined && v !== null && v !== "") url.searchParams.set(q, String(v));
|
|
21
|
+
}
|
|
22
|
+
const init: RequestInit = { method: op.method, headers: { accept: "application/json", ...headers } };
|
|
23
|
+
if (!op.readOnly && op.hasBody) {
|
|
24
|
+
(init.headers as Record<string, string>)["content-type"] = "application/json";
|
|
25
|
+
init.body = JSON.stringify(args.body ?? {});
|
|
26
|
+
}
|
|
27
|
+
return new Request(url, init);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Default executor — fetch the worker's own origin, forwarding the caller's auth so an authenticated agent acts as
|
|
31
|
+
* itself. Read-only catalog ops need no auth; mutations (only exposed under `include:"all"`) ride the forwarded
|
|
32
|
+
* session. Throws on a non-2xx so the protocol layer reports it as a tool error (`isError`). */
|
|
33
|
+
export async function originExec(c: Context, op: McpOp, args: Record<string, unknown>): Promise<unknown> {
|
|
34
|
+
const origin = new URL(c.req.url).origin;
|
|
35
|
+
const fwd: Record<string, string> = {};
|
|
36
|
+
const cookie = c.req.header("cookie"); if (cookie) fwd.cookie = cookie;
|
|
37
|
+
const auth = c.req.header("authorization"); if (auth) fwd.authorization = auth;
|
|
38
|
+
const res = await fetch(buildRequest(op, args, origin, fwd));
|
|
39
|
+
const body = await res.text();
|
|
40
|
+
if (!res.ok) throw new Error(`${op.name} → HTTP ${res.status}${body ? `: ${body.slice(0, 300)}` : ""}`);
|
|
41
|
+
if (!body) return null;
|
|
42
|
+
try { return JSON.parse(body); } catch { return body; }
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @suluk/mcp — project ONE OpenAPI v4 document into a Model Context Protocol server. The same contract that drives
|
|
3
|
+
* the API, SDK, docs, admin, and panel now drives an agent-callable surface: every operation becomes an MCP tool
|
|
4
|
+
* (read-only by default; mutations opt-in via `include:"all"`), served over the Streamable-HTTP JSON-RPC transport
|
|
5
|
+
* as a Hono-mountable app. No hand-written tool schemas, no config drift — the contract is the single source.
|
|
6
|
+
* Pure projection (`toolsFrom`) + pure protocol (`handleRpc`) are independently testable; `mcpApp` wires transport.
|
|
7
|
+
* CANDIDATE tooling — NOT official OAS.
|
|
8
|
+
*/
|
|
9
|
+
export { toolsFrom, type McpTool, type McpOp, type ToolsOptions } from "./tools";
|
|
10
|
+
export { handleRpc, LATEST_PROTOCOL, SUPPORTED_PROTOCOLS, type RpcRequest, type RpcResponse, type RpcContext, type ToolExec } from "./protocol";
|
|
11
|
+
export { buildRequest, originExec } from "./exec";
|
|
12
|
+
export { mcpApp, type McpOptions } from "./app";
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol.ts — the MCP JSON-RPC 2.0 method surface, PURE (transport injected via `exec`). Implements the minimal
|
|
3
|
+
* conformant request/response subset of the 2025-06-18 "Streamable HTTP" spec: `initialize`, `tools/list`,
|
|
4
|
+
* `tools/call`, `ping`, and notification swallowing. Tool failures are reported IN-BAND (`isError: true`) — only
|
|
5
|
+
* malformed requests / unknown methods become JSON-RPC protocol errors, exactly as the spec prescribes.
|
|
6
|
+
*/
|
|
7
|
+
import type { McpOp, McpTool } from "./tools";
|
|
8
|
+
|
|
9
|
+
export const LATEST_PROTOCOL = "2025-06-18";
|
|
10
|
+
export const SUPPORTED_PROTOCOLS = new Set(["2024-11-05", "2025-03-26", "2025-06-18"]);
|
|
11
|
+
|
|
12
|
+
export interface RpcRequest { jsonrpc?: string; id?: string | number | null; method?: string; params?: Record<string, unknown> }
|
|
13
|
+
export interface RpcResponse { jsonrpc: "2.0"; id: string | number | null; result?: unknown; error?: { code: number; message: string; data?: unknown } }
|
|
14
|
+
|
|
15
|
+
export type ToolExec = (op: McpOp, args: Record<string, unknown>) => Promise<unknown>;
|
|
16
|
+
|
|
17
|
+
export interface RpcContext {
|
|
18
|
+
tools: McpTool[];
|
|
19
|
+
info: { name: string; version: string };
|
|
20
|
+
exec: ToolExec;
|
|
21
|
+
/** Server's preferred protocol version (echoed back only if the client didn't pin a supported one). */
|
|
22
|
+
protocolVersion?: string;
|
|
23
|
+
/** Optional free-text usage guidance surfaced to the model on `initialize`. */
|
|
24
|
+
instructions?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const err = (id: RpcRequest["id"], code: number, message: string, data?: unknown): RpcResponse =>
|
|
28
|
+
({ jsonrpc: "2.0", id: id ?? null, error: { code, message, ...(data !== undefined ? { data } : {}) } });
|
|
29
|
+
const ok = (id: RpcRequest["id"], result: unknown): RpcResponse => ({ jsonrpc: "2.0", id: id ?? null, result });
|
|
30
|
+
|
|
31
|
+
/** Dispatch one JSON-RPC message. Returns `null` ONLY for a notification — a message with no `id` MEMBER; the caller
|
|
32
|
+
* then emits no body. Anything carrying an `id` (even the discouraged `id: null`) always gets a correlated response. */
|
|
33
|
+
export async function handleRpc(msg: RpcRequest, ctx: RpcContext): Promise<RpcResponse | null> {
|
|
34
|
+
if (msg === null || typeof msg !== "object" || Array.isArray(msg)) return err(null, -32600, "Invalid Request");
|
|
35
|
+
const method = msg.method;
|
|
36
|
+
const isNotification = !("id" in msg); // notification = NO id member; `id:null` is a (discouraged) request id, not this
|
|
37
|
+
if (typeof method !== "string") return isNotification ? null : err(msg.id, -32600, "Invalid Request: missing method");
|
|
38
|
+
|
|
39
|
+
// Notifications (e.g. notifications/initialized, notifications/cancelled) get no response.
|
|
40
|
+
if (method.startsWith("notifications/")) return null;
|
|
41
|
+
|
|
42
|
+
switch (method) {
|
|
43
|
+
case "initialize": {
|
|
44
|
+
const wanted = (msg.params?.protocolVersion as string | undefined);
|
|
45
|
+
const protocolVersion = wanted && SUPPORTED_PROTOCOLS.has(wanted) ? wanted : (ctx.protocolVersion ?? LATEST_PROTOCOL);
|
|
46
|
+
return ok(msg.id, {
|
|
47
|
+
protocolVersion,
|
|
48
|
+
capabilities: { tools: { listChanged: false } },
|
|
49
|
+
serverInfo: ctx.info,
|
|
50
|
+
...(ctx.instructions ? { instructions: ctx.instructions } : {}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
case "ping":
|
|
54
|
+
return ok(msg.id, {});
|
|
55
|
+
case "tools/list":
|
|
56
|
+
return ok(msg.id, { tools: ctx.tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) });
|
|
57
|
+
case "tools/call": {
|
|
58
|
+
const name = msg.params?.name;
|
|
59
|
+
const args = (msg.params?.arguments as Record<string, unknown> | undefined) ?? {};
|
|
60
|
+
if (typeof name !== "string") return err(msg.id, -32602, "Invalid params: 'name' is required");
|
|
61
|
+
const tool = ctx.tools.find((t) => t.name === name);
|
|
62
|
+
if (!tool) return err(msg.id, -32602, `Unknown tool: ${name}`);
|
|
63
|
+
try {
|
|
64
|
+
const data = await ctx.exec(tool.op, args);
|
|
65
|
+
const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
66
|
+
const structured = data !== null && typeof data === "object" ? { structuredContent: data as Record<string, unknown> } : {};
|
|
67
|
+
return ok(msg.id, { content: [{ type: "text", text }], ...structured });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
// Execution failures are TOOL results, not protocol errors (spec §Tools/Error handling).
|
|
70
|
+
return ok(msg.id, { content: [{ type: "text", text: `Error: ${(e as Error).message}` }], isError: true });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
default:
|
|
74
|
+
return isNotification ? null : err(msg.id, -32601, `Method not found: ${method}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools.ts — project an OpenAPI v4 document's operations into MCP tool descriptors. PURE: no transport, no fetch.
|
|
3
|
+
* Each `Request` (an operation; the key in `pathItem.requests` is its name/identity, C009) becomes one tool whose
|
|
4
|
+
* `inputSchema` is the operation's path + query params (flattened) plus, for mutations, a nested `body`. The op
|
|
5
|
+
* metadata (`McpOp`) is what the executor needs to turn a tool call back into an HTTP request.
|
|
6
|
+
*/
|
|
7
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
8
|
+
|
|
9
|
+
export interface McpOp {
|
|
10
|
+
/** Tool name (sanitized to MCP rules) — also how the executor finds the operation. */
|
|
11
|
+
name: string;
|
|
12
|
+
method: string;
|
|
13
|
+
/** Path template with a leading slash, e.g. `/product/{id}`. */
|
|
14
|
+
path: string;
|
|
15
|
+
/** Path-template variable names, in template order — all required. */
|
|
16
|
+
pathParams: string[];
|
|
17
|
+
/** Query parameter names. */
|
|
18
|
+
queryParams: string[];
|
|
19
|
+
/** Whether this op carries a request body (the tool exposes it under `body`). */
|
|
20
|
+
hasBody: boolean;
|
|
21
|
+
/** GET/HEAD — safe, side-effect-free. The default projection only exposes these. */
|
|
22
|
+
readOnly: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface McpTool {
|
|
26
|
+
name: string;
|
|
27
|
+
description: string;
|
|
28
|
+
inputSchema: { type: "object"; properties: Record<string, unknown>; required?: string[]; additionalProperties: boolean };
|
|
29
|
+
op: McpOp;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToolsOptions {
|
|
33
|
+
/** `"read"` (default) exposes only GET/HEAD operations; `"all"` also exposes mutations. */
|
|
34
|
+
include?: "read" | "all";
|
|
35
|
+
/** Operation names to omit. */
|
|
36
|
+
hide?: string[];
|
|
37
|
+
/** If set, expose ONLY these operation names (after hide). */
|
|
38
|
+
only?: string[];
|
|
39
|
+
/** Include `deprecated` operations (default: skip them). */
|
|
40
|
+
includeDeprecated?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const RESERVED_BODY = "body";
|
|
44
|
+
|
|
45
|
+
/** MCP tool names must be `[A-Za-z0-9_-]{1,64}`. Operation names usually already comply; sanitize defensively. */
|
|
46
|
+
function toolName(raw: string): string {
|
|
47
|
+
const cleaned = raw.replace(/[^A-Za-z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "op";
|
|
48
|
+
return cleaned.slice(0, 64);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function deref(node: unknown, doc: OpenAPIv4Document, seen = new Set<string>()): Record<string, unknown> | undefined {
|
|
52
|
+
if (!node || typeof node !== "object") return undefined;
|
|
53
|
+
const ref = (node as { $ref?: string }).$ref;
|
|
54
|
+
if (typeof ref === "string") {
|
|
55
|
+
if (seen.has(ref)) return undefined; // cycle guard
|
|
56
|
+
seen.add(ref);
|
|
57
|
+
const m = /^#\/components\/schemas\/(.+)$/.exec(ref);
|
|
58
|
+
const name = m?.[1];
|
|
59
|
+
const target = name ? (doc.components?.schemas as Record<string, unknown> | undefined)?.[name] : undefined;
|
|
60
|
+
return target ? deref(target, doc, seen) : undefined;
|
|
61
|
+
}
|
|
62
|
+
return node as Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pull `{properties, required}` out of an (object) schema, dereferencing a top-level $ref first. */
|
|
66
|
+
function objectShape(schema: unknown, doc: OpenAPIv4Document): { properties: Record<string, unknown>; required: string[] } {
|
|
67
|
+
const s = deref(schema, doc);
|
|
68
|
+
const properties = (s?.properties && typeof s.properties === "object" ? (s.properties as Record<string, unknown>) : {});
|
|
69
|
+
const required = Array.isArray(s?.required) ? (s!.required as string[]) : [];
|
|
70
|
+
return { properties, required };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Extract `{name}` template variables from a path, in order. */
|
|
74
|
+
function templateVars(path: string): string[] {
|
|
75
|
+
return Array.from(path.matchAll(/\{([^}]+)\}/g), (m) => m[1]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function toolsFrom(doc: OpenAPIv4Document, opts: ToolsOptions = {}): McpTool[] {
|
|
79
|
+
const include = opts.include ?? "read";
|
|
80
|
+
const hide = new Set(opts.hide ?? []);
|
|
81
|
+
const only = opts.only ? new Set(opts.only) : null;
|
|
82
|
+
const out: McpTool[] = [];
|
|
83
|
+
const used = new Set<string>();
|
|
84
|
+
|
|
85
|
+
for (const [rawPath, pathItem] of Object.entries(doc.paths ?? {})) {
|
|
86
|
+
const requests = (pathItem as { requests?: Record<string, unknown> }).requests ?? {};
|
|
87
|
+
const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
88
|
+
const pathVars = templateVars(path);
|
|
89
|
+
|
|
90
|
+
for (const [opName, reqRaw] of Object.entries(requests)) {
|
|
91
|
+
const req = reqRaw as {
|
|
92
|
+
method?: string; summary?: string; description?: string; deprecated?: boolean;
|
|
93
|
+
parameterSchema?: { path?: unknown; query?: unknown; body?: unknown };
|
|
94
|
+
contentSchema?: unknown;
|
|
95
|
+
};
|
|
96
|
+
const method = String(req.method ?? "GET").toUpperCase();
|
|
97
|
+
const readOnly = method === "GET" || method === "HEAD";
|
|
98
|
+
|
|
99
|
+
if (req.deprecated && !opts.includeDeprecated) continue;
|
|
100
|
+
if (include === "read" && !readOnly) continue;
|
|
101
|
+
if (hide.has(opName)) continue;
|
|
102
|
+
if (only && !only.has(opName)) continue;
|
|
103
|
+
|
|
104
|
+
const ps = req.parameterSchema ?? {};
|
|
105
|
+
const pathShape = objectShape(ps.path, doc);
|
|
106
|
+
const queryShape = objectShape(ps.query, doc);
|
|
107
|
+
const bodySchema = ps.body ?? req.contentSchema;
|
|
108
|
+
const hasBody = !readOnly && bodySchema != null;
|
|
109
|
+
|
|
110
|
+
const properties: Record<string, unknown> = {};
|
|
111
|
+
const required: string[] = [];
|
|
112
|
+
// Every template var IS a path param (the uriTemplate is authoritative, C019); the path schema only adds types.
|
|
113
|
+
// Union them — using one OR the other drops a var the other side declares, yielding an unsubstitutable {var}.
|
|
114
|
+
const pathNames = Array.from(new Set([...pathVars, ...Object.keys(pathShape.properties)]));
|
|
115
|
+
for (const p of pathNames) { properties[p] = pathShape.properties[p] ?? { type: "string" }; required.push(p); }
|
|
116
|
+
for (const [q, schema] of Object.entries(queryShape.properties)) { if (q in properties) continue; properties[q] = schema; if (queryShape.required.includes(q)) required.push(q); }
|
|
117
|
+
if (hasBody) { properties[RESERVED_BODY] = deref(bodySchema, doc) ?? bodySchema; required.push(RESERVED_BODY); }
|
|
118
|
+
|
|
119
|
+
// Dedup colliding names while keeping inside MCP's 64-char limit: RESERVE room for the suffix rather than
|
|
120
|
+
// re-truncating it away (which would loop forever when two ops share an already-64-char sanitized prefix).
|
|
121
|
+
const base = toolName(opName);
|
|
122
|
+
let name = base;
|
|
123
|
+
for (let i = 1; used.has(name); i++) { const sfx = `_${i}`; name = base.slice(0, 64 - sfx.length) + sfx; }
|
|
124
|
+
used.add(name);
|
|
125
|
+
|
|
126
|
+
out.push({
|
|
127
|
+
name,
|
|
128
|
+
description: req.summary || req.description || `${method} ${path}`,
|
|
129
|
+
inputSchema: { type: "object", properties, ...(required.length ? { required } : {}), additionalProperties: false },
|
|
130
|
+
op: { name, method, path, pathParams: pathNames, queryParams: Object.keys(queryShape.properties), hasBody, readOnly },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
package/test/mcp.test.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { toolsFrom, handleRpc, buildRequest, mcpApp, LATEST_PROTOCOL, type RpcContext } from "../src/index";
|
|
3
|
+
|
|
4
|
+
// A tiny v4-shaped document: a read op with a path param + query, and a mutation with a body.
|
|
5
|
+
const doc = {
|
|
6
|
+
components: { schemas: { ProductCreate: { type: "object", required: ["name"], properties: { name: { type: "string" }, priceCents: { type: "integer" } } } } },
|
|
7
|
+
paths: {
|
|
8
|
+
"product": { requests: {
|
|
9
|
+
listProduct: { method: "GET", summary: "List products", parameterSchema: { query: { type: "object", properties: { limit: { type: "integer" }, q: { type: "string" } }, required: ["limit"] } } },
|
|
10
|
+
createProduct: { method: "POST", summary: "Create a product", parameterSchema: { body: { $ref: "#/components/schemas/ProductCreate" } } },
|
|
11
|
+
} },
|
|
12
|
+
"product/{id}": { requests: {
|
|
13
|
+
getProduct: { method: "GET", summary: "Get one product", parameterSchema: { path: { type: "object", properties: { id: { type: "integer" } } } } },
|
|
14
|
+
deleteProduct: { method: "DELETE", summary: "Delete a product", deprecated: true, parameterSchema: { path: { type: "object", properties: { id: { type: "integer" } } } } },
|
|
15
|
+
} },
|
|
16
|
+
},
|
|
17
|
+
} as never;
|
|
18
|
+
|
|
19
|
+
describe("toolsFrom — contract → MCP tools", () => {
|
|
20
|
+
test("read-only by default: only GET ops, mutations + deprecated excluded", () => {
|
|
21
|
+
const names = toolsFrom(doc).map((t) => t.name).sort();
|
|
22
|
+
expect(names).toEqual(["getProduct", "listProduct"]); // createProduct (POST) + deleteProduct (deprecated) excluded
|
|
23
|
+
});
|
|
24
|
+
test("inputSchema flattens path + query params; path param required", () => {
|
|
25
|
+
const get = toolsFrom(doc).find((t) => t.name === "getProduct")!;
|
|
26
|
+
expect(get.inputSchema.properties).toHaveProperty("id");
|
|
27
|
+
expect(get.inputSchema.required).toEqual(["id"]);
|
|
28
|
+
expect(get.op).toMatchObject({ method: "GET", path: "/product/{id}", pathParams: ["id"], readOnly: true });
|
|
29
|
+
const list = toolsFrom(doc).find((t) => t.name === "listProduct")!;
|
|
30
|
+
expect(Object.keys(list.inputSchema.properties).sort()).toEqual(["limit", "q"]);
|
|
31
|
+
expect(list.inputSchema.required).toEqual(["limit"]);
|
|
32
|
+
expect(list.op.queryParams.sort()).toEqual(["limit", "q"]);
|
|
33
|
+
});
|
|
34
|
+
test("include:'all' exposes mutations with a deref'd body schema under `body`", () => {
|
|
35
|
+
const tools = toolsFrom(doc, { include: "all" });
|
|
36
|
+
const create = tools.find((t) => t.name === "createProduct")!;
|
|
37
|
+
expect(create.op.method).toBe("POST");
|
|
38
|
+
expect(create.op.hasBody).toBe(true);
|
|
39
|
+
expect(create.inputSchema.required).toContain("body");
|
|
40
|
+
expect((create.inputSchema.properties.body as { properties: object }).properties).toHaveProperty("name"); // $ref resolved
|
|
41
|
+
});
|
|
42
|
+
test("hide / only filters", () => {
|
|
43
|
+
expect(toolsFrom(doc, { hide: ["listProduct"] }).map((t) => t.name)).toEqual(["getProduct"]);
|
|
44
|
+
expect(toolsFrom(doc, { only: ["listProduct"] }).map((t) => t.name)).toEqual(["listProduct"]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("buildRequest — SSRF-safe HTTP projection", () => {
|
|
49
|
+
test("path params encoded into the template; query set on the URL; origin never escapes", () => {
|
|
50
|
+
const op = toolsFrom(doc).find((t) => t.name === "getProduct")!.op;
|
|
51
|
+
const req = buildRequest(op, { id: "../../etc/passwd" }, "https://shop.example");
|
|
52
|
+
const u = new URL(req.url);
|
|
53
|
+
expect(u.origin).toBe("https://shop.example"); // host is fixed — no SSRF
|
|
54
|
+
expect(u.pathname).toBe("/product/..%2F..%2Fetc%2Fpasswd"); // traversal neutralized by encodeURIComponent
|
|
55
|
+
});
|
|
56
|
+
test("query op: empty/nullish values dropped, present ones encoded", () => {
|
|
57
|
+
const op = toolsFrom(doc).find((t) => t.name === "listProduct")!.op;
|
|
58
|
+
const u = new URL(buildRequest(op, { limit: 10, q: "a b&c" }, "https://shop.example").url);
|
|
59
|
+
expect(u.searchParams.get("limit")).toBe("10");
|
|
60
|
+
expect(u.searchParams.get("q")).toBe("a b&c");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("handleRpc — JSON-RPC surface", () => {
|
|
65
|
+
const ctx: RpcContext = { tools: toolsFrom(doc), info: { name: "test", version: "9" }, exec: async (op, args) => ({ ran: op.name, args }) };
|
|
66
|
+
test("initialize negotiates protocol + advertises tools capability", async () => {
|
|
67
|
+
const r = await handleRpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26" } }, ctx);
|
|
68
|
+
expect(r!.result).toMatchObject({ protocolVersion: "2025-03-26", capabilities: { tools: { listChanged: false } }, serverInfo: { name: "test" } });
|
|
69
|
+
const r2 = await handleRpc({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "9999-99-99" } }, ctx);
|
|
70
|
+
expect((r2!.result as { protocolVersion: string }).protocolVersion).toBe(LATEST_PROTOCOL); // unsupported → server default
|
|
71
|
+
});
|
|
72
|
+
test("notifications get no response", async () => {
|
|
73
|
+
expect(await handleRpc({ jsonrpc: "2.0", method: "notifications/initialized" }, ctx)).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
test("tools/list returns name+description+inputSchema only", async () => {
|
|
76
|
+
const r = await handleRpc({ jsonrpc: "2.0", id: 2, method: "tools/list" }, ctx);
|
|
77
|
+
const tools = (r!.result as { tools: { name: string }[] }).tools;
|
|
78
|
+
expect(tools.map((t) => t.name).sort()).toEqual(["getProduct", "listProduct"]);
|
|
79
|
+
expect(tools[0]).toHaveProperty("inputSchema");
|
|
80
|
+
});
|
|
81
|
+
test("tools/call runs exec and returns text + structuredContent", async () => {
|
|
82
|
+
const r = await handleRpc({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "getProduct", arguments: { id: 7 } } }, ctx);
|
|
83
|
+
const res = r!.result as { content: { type: string; text: string }[]; structuredContent: { ran: string } };
|
|
84
|
+
expect(res.content[0].type).toBe("text");
|
|
85
|
+
expect(res.structuredContent.ran).toBe("getProduct");
|
|
86
|
+
});
|
|
87
|
+
test("unknown tool → invalid params; tool throw → in-band isError (not a protocol error)", async () => {
|
|
88
|
+
const bad = await handleRpc({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "nope" } }, ctx);
|
|
89
|
+
expect(bad!.error!.code).toBe(-32602);
|
|
90
|
+
const throwing: RpcContext = { ...ctx, exec: async () => { throw new Error("boom"); } };
|
|
91
|
+
const r = await handleRpc({ jsonrpc: "2.0", id: 5, method: "tools/call", params: { name: "getProduct", arguments: {} } }, throwing);
|
|
92
|
+
expect(r!.error).toBeUndefined();
|
|
93
|
+
expect((r!.result as { isError: boolean }).isError).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
test("unknown method → -32601", async () => {
|
|
96
|
+
const r = await handleRpc({ jsonrpc: "2.0", id: 6, method: "no/such" }, ctx);
|
|
97
|
+
expect(r!.error!.code).toBe(-32601);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("mcpApp — transport", () => {
|
|
102
|
+
const app = mcpApp({ document: doc, name: "shop", exec: async (_c, op) => ({ ok: op.name }) });
|
|
103
|
+
test("GET → 405 (no server stream); OPTIONS → 204 CORS; POST initialize works", async () => {
|
|
104
|
+
expect((await app.request("/mcp", { method: "GET" })).status).toBe(405);
|
|
105
|
+
const opt = await app.request("/mcp", { method: "OPTIONS" });
|
|
106
|
+
expect(opt.status).toBe(204);
|
|
107
|
+
expect(opt.headers.get("access-control-allow-origin")).toBe("*");
|
|
108
|
+
const res = await app.request("/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize" }) });
|
|
109
|
+
expect(res.status).toBe(200);
|
|
110
|
+
expect((await res.json()).result.serverInfo.name).toBe("shop");
|
|
111
|
+
});
|
|
112
|
+
test("a notification-only POST → 202 no body", async () => {
|
|
113
|
+
const res = await app.request("/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) });
|
|
114
|
+
expect(res.status).toBe(202);
|
|
115
|
+
});
|
|
116
|
+
test("authorize:false → 401", async () => {
|
|
117
|
+
const gated = mcpApp({ document: doc, authorize: () => false });
|
|
118
|
+
const res = await gated.request("/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }) });
|
|
119
|
+
expect(res.status).toBe(401);
|
|
120
|
+
});
|
|
121
|
+
test("MCP 2025-06-18: a batch (array) POST is rejected, not fanned out", async () => {
|
|
122
|
+
const res = await app.request("/mcp", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify([{ jsonrpc: "2.0", id: 1, method: "ping" }]) });
|
|
123
|
+
expect(res.status).toBe(400);
|
|
124
|
+
expect((await res.json()).error.code).toBe(-32600);
|
|
125
|
+
});
|
|
126
|
+
test("oversized body → 413 before parse", async () => {
|
|
127
|
+
const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping", params: { pad: "x".repeat(300 * 1024) } });
|
|
128
|
+
const res = await app.request("/mcp", { method: "POST", headers: { "content-type": "application/json", "content-length": String(huge.length) }, body: huge });
|
|
129
|
+
expect(res.status).toBe(413);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("review-hardening regressions", () => {
|
|
134
|
+
test("name dedup terminates + stays ≤64 chars for >64-char ops sharing a prefix", () => {
|
|
135
|
+
const long = "x".repeat(64);
|
|
136
|
+
const requests = { [long + "A"]: { method: "GET" }, [long + "B"]: { method: "GET" } };
|
|
137
|
+
const names = toolsFrom({ paths: { thing: { requests } } } as never).map((t) => t.name);
|
|
138
|
+
expect(names.length).toBe(2);
|
|
139
|
+
expect(names[0]).not.toBe(names[1]);
|
|
140
|
+
expect(names.every((n) => n.length <= 64 && /^[A-Za-z0-9_-]+$/.test(n))).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
test("every path template var becomes a path param even when the path schema only types some of them", () => {
|
|
143
|
+
const getOrgProduct = { method: "GET", parameterSchema: { path: { type: "object", properties: { id: { type: "integer" } } } } };
|
|
144
|
+
const d = { paths: { "/org/{org}/product/{id}": { requests: { getOrgProduct } } } } as never;
|
|
145
|
+
const t = toolsFrom(d)[0];
|
|
146
|
+
expect(t.op.pathParams.sort()).toEqual(["id", "org"]);
|
|
147
|
+
expect(Object.keys(t.inputSchema.properties).sort()).toEqual(["id", "org"]);
|
|
148
|
+
const u = new URL(buildRequest(t.op, { org: "acme", id: 7 }, "https://shop.example").url);
|
|
149
|
+
expect(u.pathname).toBe("/org/acme/product/7"); // both vars substituted — no literal {org}
|
|
150
|
+
});
|
|
151
|
+
test("a request with id:null still gets a correlated error (not swallowed as a notification)", async () => {
|
|
152
|
+
const ctx: RpcContext = { tools: toolsFrom(doc), info: { name: "t", version: "1" }, exec: async () => ({}) };
|
|
153
|
+
const r = await handleRpc({ jsonrpc: "2.0", id: null, method: "no/such" }, ctx);
|
|
154
|
+
expect(r).not.toBeNull();
|
|
155
|
+
expect(r!.error!.code).toBe(-32601);
|
|
156
|
+
});
|
|
157
|
+
test("a non-object message → -32600 Invalid Request", async () => {
|
|
158
|
+
const ctx: RpcContext = { tools: [], info: { name: "t", version: "1" }, exec: async () => ({}) };
|
|
159
|
+
expect((await handleRpc(1 as never, ctx))!.error!.code).toBe(-32600);
|
|
160
|
+
expect((await handleRpc(null as never, ctx))!.error!.code).toBe(-32600);
|
|
161
|
+
});
|
|
162
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test"] }
|