openapi-to-mcp-bridge-mcp 1.0.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.
Files changed (4) hide show
  1. package/README.md +47 -0
  2. package/index.js +399 -0
  3. package/mcpize.yaml +12 -0
  4. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,47 @@
1
+ # openapi-to-mcp-bridge-mcp
2
+
3
+ Bridge any OpenAPI 3.x spec → MCP server in one call. Inspect tools, preview JSON Schemas, generate a runnable Node MCP server, or stream-call endpoints live without writing a wrapper.
4
+
5
+ ## Why
6
+ Every AI agent dev wants to give Claude/GPT access to their existing REST APIs. Today they write MCP wrappers by hand — one tool per endpoint, schemas duplicated, auth boilerplate. This bridge eats an OpenAPI spec and emits MCP — automatically.
7
+
8
+ ## Install
9
+ ```bash
10
+ npm install -g openapi-to-mcp-bridge-mcp
11
+ ```
12
+
13
+ ## Tools
14
+
15
+ | Tool | What it does |
16
+ |------|--------------|
17
+ | `from_url` | Fetch spec from URL → summary (info / base URL / operation list) |
18
+ | `from_spec` | Same, but takes inline JSON/YAML |
19
+ | `preview_tools` | Return MCP-shaped `{name, description, inputSchema}` for every operation — paste into any MCP host |
20
+ | `generate_server` | Emit a runnable Node ESM MCP server wrapping all operations |
21
+ | `call_endpoint` | Fire one HTTP call against an `operationId` with validated args |
22
+
23
+ ## Quickstart
24
+
25
+ ```jsonc
26
+ // claude_desktop_config.json
27
+ {
28
+ "mcpServers": {
29
+ "openapi-bridge": {
30
+ "command": "npx",
31
+ "args": ["-y", "openapi-to-mcp-bridge-mcp"]
32
+ }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Then ask Claude:
38
+ > "Use openapi-bridge.from_url with https://petstore3.swagger.io/api/v3/openapi.json — show me the tool list."
39
+
40
+ ## Generated server runtime env
41
+
42
+ The output of `generate_server` reads:
43
+ - `OPENAPI_BASE_URL` — override `servers[0].url`
44
+ - `OPENAPI_AUTH_HEADER` — single header to inject, e.g. `Authorization: Bearer XYZ`
45
+
46
+ ## License
47
+ MIT
package/index.js ADDED
@@ -0,0 +1,399 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * openapi-to-mcp-bridge-mcp
4
+ * ────────────────────────
5
+ * One MCP server that turns any OpenAPI 3.x spec into MCP tools.
6
+ *
7
+ * Tools:
8
+ * from_url — fetch spec from URL, normalize, list ops as MCP tools
9
+ * from_spec — same as from_url but takes spec inline (string or JSON)
10
+ * preview_tools — return MCP-shaped tool definitions for a spec (name, desc, inputSchema)
11
+ * generate_server — emit a runnable Node MCP server (Node ESM) wrapping all operations
12
+ * call_endpoint — fire one HTTP call against an operationId with validated args
13
+ */
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { z } from "zod";
18
+
19
+ // ─── Spec loading ─────────────────────────────────────────────────────────
20
+ function safeJson(s, fallback = null) { try { return JSON.parse(s); } catch { return fallback; } }
21
+
22
+ function looksLikeYaml(s) {
23
+ if (typeof s !== "string") return false;
24
+ const t = s.trim();
25
+ if (!t || t.startsWith("{") || t.startsWith("[")) return false;
26
+ return /^(openapi|swagger|info|paths|components):/m.test(t);
27
+ }
28
+
29
+ // Minimal YAML-ish loader — handles flat OpenAPI specs reasonably; for complex anchors
30
+ // the caller should pre-convert to JSON. We try JSON first then a forgiving YAML parser.
31
+ function parseSpec(raw) {
32
+ if (raw && typeof raw === "object") return raw;
33
+ const s = String(raw || "");
34
+ const j = safeJson(s);
35
+ if (j) return j;
36
+ if (looksLikeYaml(s)) {
37
+ // Cheap two-pass YAML→JSON: convert lines to objects. Good enough for spec parsing
38
+ // when no anchors/multiline scalars are used.
39
+ return naiveYamlParse(s);
40
+ }
41
+ throw new Error("Spec must be JSON or simple YAML; pass it via from_spec with valid content.");
42
+ }
43
+
44
+ function naiveYamlParse(text) {
45
+ // Stack-based indent parser: handles mappings + sequences + scalar leaves.
46
+ const lines = text.split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith("#"));
47
+ const root = {};
48
+ const stack = [{ indent: -1, value: root }];
49
+ for (const raw of lines) {
50
+ const indent = raw.match(/^( *)/)[1].length;
51
+ const line = raw.trim();
52
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
53
+ const parent = stack[stack.length - 1].value;
54
+ if (line.startsWith("- ")) {
55
+ const item = line.slice(2);
56
+ if (!Array.isArray(parent.__list)) parent.__list = [];
57
+ if (item.includes(": ")) {
58
+ const [k, v] = splitKV(item);
59
+ const obj = { [k]: castScalar(v) };
60
+ parent.__list.push(obj);
61
+ stack.push({ indent, value: obj });
62
+ } else if (item.endsWith(":")) {
63
+ const k = item.slice(0, -1);
64
+ const obj = { [k]: {} };
65
+ parent.__list.push(obj);
66
+ stack.push({ indent, value: obj[k] });
67
+ } else {
68
+ parent.__list.push(castScalar(item));
69
+ }
70
+ } else if (line.endsWith(":")) {
71
+ const k = line.slice(0, -1);
72
+ parent[k] = {};
73
+ stack.push({ indent, value: parent[k] });
74
+ } else if (line.includes(": ")) {
75
+ const [k, v] = splitKV(line);
76
+ parent[k] = castScalar(v);
77
+ }
78
+ }
79
+ return liftLists(root);
80
+ }
81
+ function splitKV(s) {
82
+ const i = s.indexOf(": ");
83
+ return [s.slice(0, i).trim(), s.slice(i + 2).trim()];
84
+ }
85
+ function castScalar(v) {
86
+ if (v == null) return null;
87
+ if (/^(true|false)$/i.test(v)) return v.toLowerCase() === "true";
88
+ if (/^-?\d+$/.test(v)) return parseInt(v, 10);
89
+ if (/^-?\d*\.\d+$/.test(v)) return parseFloat(v);
90
+ return v.replace(/^["']|["']$/g, "");
91
+ }
92
+ function liftLists(node) {
93
+ if (Array.isArray(node)) return node.map(liftLists);
94
+ if (node && typeof node === "object") {
95
+ if (node.__list) return node.__list.map(liftLists);
96
+ const out = {};
97
+ for (const k of Object.keys(node)) out[k] = liftLists(node[k]);
98
+ return out;
99
+ }
100
+ return node;
101
+ }
102
+
103
+ async function fetchSpec(url) {
104
+ const ctrl = typeof AbortController === "function" ? new AbortController() : null;
105
+ const t = ctrl ? setTimeout(() => ctrl.abort(), 15000) : null;
106
+ try {
107
+ const res = await fetch(url, { signal: ctrl?.signal, headers: { "user-agent": "openapi-to-mcp-bridge/1.0" } });
108
+ if (!res.ok) throw new Error(`HTTP ${res.status} fetching ${url}`);
109
+ const text = await res.text();
110
+ return parseSpec(text);
111
+ } finally {
112
+ if (t) clearTimeout(t);
113
+ }
114
+ }
115
+
116
+ // ─── Spec → tool definitions ─────────────────────────────────────────────
117
+ const PRIM_MAP = { string: "string", integer: "number", number: "number", boolean: "boolean", array: "array", object: "object" };
118
+
119
+ function paramSchema(p) {
120
+ const s = p.schema || {};
121
+ const t = PRIM_MAP[s.type] || "string";
122
+ const out = { type: t };
123
+ if (s.enum) out.enum = s.enum;
124
+ if (s.format) out.format = s.format;
125
+ if (s.minimum != null) out.minimum = s.minimum;
126
+ if (s.maximum != null) out.maximum = s.maximum;
127
+ if (s.default != null) out.default = s.default;
128
+ if (s.items) out.items = { type: PRIM_MAP[s.items.type] || "string" };
129
+ if (p.description) out.description = p.description;
130
+ return out;
131
+ }
132
+
133
+ function bodySchema(reqBody, spec) {
134
+ if (!reqBody) return null;
135
+ const c = reqBody.content || {};
136
+ const json = c["application/json"] || c["application/*+json"] || c[Object.keys(c)[0]];
137
+ if (!json) return null;
138
+ const schema = resolveRef(json.schema, spec) || { type: "object" };
139
+ return schema;
140
+ }
141
+
142
+ function resolveRef(node, spec) {
143
+ if (!node || typeof node !== "object") return node;
144
+ if (typeof node.$ref === "string") {
145
+ const path = node.$ref.replace(/^#\//, "").split("/");
146
+ let cur = spec;
147
+ for (const seg of path) { if (cur == null) return null; cur = cur[seg]; }
148
+ return resolveRef(cur, spec);
149
+ }
150
+ return node;
151
+ }
152
+
153
+ function opToTool(op, path, method, spec) {
154
+ const opId = op.operationId || `${method.toLowerCase()}_${path.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "")}`;
155
+ const params = (op.parameters || []).map((p) => resolveRef(p, spec)).filter(Boolean);
156
+ const properties = {};
157
+ const required = [];
158
+ for (const p of params) {
159
+ properties[p.name] = paramSchema(p);
160
+ properties[p.name].__in = p.in;
161
+ if (p.required) required.push(p.name);
162
+ }
163
+ const body = bodySchema(resolveRef(op.requestBody, spec), spec);
164
+ if (body) {
165
+ properties.body = { ...body, description: "Request body (JSON)" };
166
+ if (op.requestBody?.required) required.push("body");
167
+ }
168
+ return {
169
+ name: opId,
170
+ description: (op.summary || op.description || `${method.toUpperCase()} ${path}`).slice(0, 280),
171
+ method: method.toUpperCase(),
172
+ path,
173
+ inputSchema: { type: "object", properties, required },
174
+ tags: op.tags || [],
175
+ };
176
+ }
177
+
178
+ function specToTools(spec) {
179
+ const tools = [];
180
+ const paths = spec.paths || {};
181
+ for (const [p, pi] of Object.entries(paths)) {
182
+ for (const m of ["get","post","put","patch","delete","head","options"]) {
183
+ if (pi[m]) tools.push(opToTool(pi[m], p, m, spec));
184
+ }
185
+ }
186
+ return tools;
187
+ }
188
+
189
+ function deriveBaseUrl(spec, override) {
190
+ if (override) return String(override).replace(/\/+$/, "");
191
+ const s = spec.servers?.[0]?.url;
192
+ if (s) return String(s).replace(/\/+$/, "");
193
+ return null;
194
+ }
195
+
196
+ // ─── Generator: produce a runnable Node MCP server ───────────────────────
197
+ function generateServerCode(spec, { name, base_url }) {
198
+ const tools = specToTools(spec);
199
+ const baseUrl = deriveBaseUrl(spec, base_url) || "https://REPLACE.example.com";
200
+ const safeName = (name || (spec.info?.title || "openapi-mcp")).toString().toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
201
+ const meta = {
202
+ name: safeName,
203
+ version: "1.0.0",
204
+ description: spec.info?.description || `${spec.info?.title || "OpenAPI"} bridged to MCP`,
205
+ };
206
+ const toolDefs = tools.map((t) => ({
207
+ name: t.name,
208
+ description: t.description,
209
+ method: t.method,
210
+ path: t.path,
211
+ inputSchema: t.inputSchema,
212
+ }));
213
+ return [
214
+ `#!/usr/bin/env node`,
215
+ `// Auto-generated by openapi-to-mcp-bridge-mcp v1.0.0`,
216
+ `// Source: ${spec.info?.title || "OpenAPI"} ${spec.info?.version || ""}`,
217
+ `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";`,
218
+ `import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";`,
219
+ `import { z } from "zod";`,
220
+ ``,
221
+ `const BASE_URL = process.env.OPENAPI_BASE_URL || ${JSON.stringify(baseUrl)};`,
222
+ `const AUTH_HEADER = process.env.OPENAPI_AUTH_HEADER || ""; // e.g. "Authorization: Bearer XYZ"`,
223
+ ``,
224
+ `const TOOLS = ${JSON.stringify(toolDefs, null, 2)};`,
225
+ ``,
226
+ `function buildUrl(path, args) {`,
227
+ ` let p = path;`,
228
+ ` const query = new URLSearchParams();`,
229
+ ` for (const [k, v] of Object.entries(args || {})) {`,
230
+ ` if (k === "body") continue;`,
231
+ ` if (p.includes("{" + k + "}")) { p = p.replace("{" + k + "}", encodeURIComponent(v)); continue; }`,
232
+ ` query.set(k, String(v));`,
233
+ ` }`,
234
+ ` const q = query.toString();`,
235
+ ` return BASE_URL + p + (q ? "?" + q : "");`,
236
+ `}`,
237
+ ``,
238
+ `async function callOp(op, args) {`,
239
+ ` const url = buildUrl(op.path, args);`,
240
+ ` const headers = { "content-type": "application/json", "accept": "application/json" };`,
241
+ ` if (AUTH_HEADER) { const [k, ...rest] = AUTH_HEADER.split(":"); headers[k.trim()] = rest.join(":").trim(); }`,
242
+ ` const body = args && args.body != null ? JSON.stringify(args.body) : undefined;`,
243
+ ` const res = await fetch(url, { method: op.method, headers, body });`,
244
+ ` const text = await res.text();`,
245
+ ` try { return { ok: res.ok, status: res.status, data: JSON.parse(text) }; }`,
246
+ ` catch { return { ok: res.ok, status: res.status, data: text }; }`,
247
+ `}`,
248
+ ``,
249
+ `const server = new McpServer(${JSON.stringify(meta, null, 2)});`,
250
+ ``,
251
+ `for (const op of TOOLS) {`,
252
+ ` const shape = {};`,
253
+ ` for (const [name, schema] of Object.entries(op.inputSchema.properties || {})) {`,
254
+ ` let s = z.any();`,
255
+ ` if (schema.type === "string") s = z.string();`,
256
+ ` else if (schema.type === "number") s = z.number();`,
257
+ ` else if (schema.type === "boolean") s = z.boolean();`,
258
+ ` else if (schema.type === "array") s = z.array(z.any());`,
259
+ ` if (!(op.inputSchema.required || []).includes(name)) s = s.optional();`,
260
+ ` if (schema.description) s = s.describe(schema.description);`,
261
+ ` shape[name] = s;`,
262
+ ` }`,
263
+ ` server.tool(op.name, op.description, shape, async (args) => {`,
264
+ ` const out = await callOp(op, args);`,
265
+ ` return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };`,
266
+ ` });`,
267
+ `}`,
268
+ ``,
269
+ `await server.connect(new StdioServerTransport());`,
270
+ ``,
271
+ ].join("\n");
272
+ }
273
+
274
+ // ─── Live caller — for the hosted bridge ─────────────────────────────────
275
+ async function liveCall({ spec, base_url, auth_header, operation_id, args }) {
276
+ const tools = specToTools(spec);
277
+ const tool = tools.find((t) => t.name === operation_id);
278
+ if (!tool) return { error: `unknown operation: ${operation_id}. Available: ${tools.slice(0, 12).map((t) => t.name).join(", ")}${tools.length > 12 ? ` (+${tools.length - 12} more)` : ""}` };
279
+ const baseUrl = deriveBaseUrl(spec, base_url);
280
+ if (!baseUrl) return { error: "no base_url — pass base_url explicitly or include a servers[] entry in the spec" };
281
+ let path = tool.path;
282
+ const query = new URLSearchParams();
283
+ for (const [k, v] of Object.entries(args || {})) {
284
+ if (k === "body") continue;
285
+ if (path.includes("{" + k + "}")) { path = path.replace("{" + k + "}", encodeURIComponent(String(v))); continue; }
286
+ query.set(k, String(v));
287
+ }
288
+ const url = baseUrl + path + (query.toString() ? "?" + query.toString() : "");
289
+ const headers = { "content-type": "application/json", "accept": "application/json", "user-agent": "openapi-to-mcp-bridge/1.0" };
290
+ if (auth_header) {
291
+ const [k, ...rest] = String(auth_header).split(":");
292
+ headers[k.trim()] = rest.join(":").trim();
293
+ }
294
+ const body = args && args.body != null ? JSON.stringify(args.body) : undefined;
295
+ const ctrl = typeof AbortController === "function" ? new AbortController() : null;
296
+ const t = ctrl ? setTimeout(() => ctrl.abort(), 20000) : null;
297
+ try {
298
+ const res = await fetch(url, { method: tool.method, headers, body, signal: ctrl?.signal });
299
+ const text = await res.text();
300
+ let data; try { data = JSON.parse(text); } catch { data = text; }
301
+ return { ok: res.ok, status: res.status, url, method: tool.method, data };
302
+ } catch (e) {
303
+ return { error: e?.message || String(e), url, method: tool.method };
304
+ } finally {
305
+ if (t) clearTimeout(t);
306
+ }
307
+ }
308
+
309
+ // ─── MCP server wiring ───────────────────────────────────────────────────
310
+ const server = new McpServer({
311
+ name: "openapi-to-mcp-bridge-mcp",
312
+ version: "1.0.0",
313
+ description: "Bridge any OpenAPI 3.x spec → MCP server in one call. Inspect tools, preview JSON Schemas, generate a runnable Node MCP server, or stream-call endpoints live.",
314
+ });
315
+
316
+ server.tool(
317
+ "from_url",
318
+ "Fetch an OpenAPI 3.x spec from URL and return a summary (info, server, tool count, tool names).",
319
+ { url: z.string().url(), base_url_override: z.string().optional() },
320
+ async ({ url, base_url_override }) => {
321
+ const spec = await fetchSpec(url);
322
+ const tools = specToTools(spec);
323
+ const summary = {
324
+ ok: true,
325
+ source: url,
326
+ title: spec.info?.title,
327
+ version: spec.info?.version,
328
+ base_url: deriveBaseUrl(spec, base_url_override),
329
+ operation_count: tools.length,
330
+ tools: tools.map((t) => ({ name: t.name, method: t.method, path: t.path, tags: t.tags, required: t.inputSchema.required })),
331
+ };
332
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
333
+ }
334
+ );
335
+
336
+ server.tool(
337
+ "from_spec",
338
+ "Parse a spec passed inline (JSON string, JSON object, or simple YAML) and return a summary.",
339
+ { spec: z.union([z.string(), z.record(z.any())]), base_url_override: z.string().optional() },
340
+ async ({ spec: raw, base_url_override }) => {
341
+ const spec = parseSpec(raw);
342
+ const tools = specToTools(spec);
343
+ const summary = {
344
+ ok: true,
345
+ title: spec.info?.title,
346
+ version: spec.info?.version,
347
+ base_url: deriveBaseUrl(spec, base_url_override),
348
+ operation_count: tools.length,
349
+ tools: tools.map((t) => ({ name: t.name, method: t.method, path: t.path })),
350
+ };
351
+ return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
352
+ }
353
+ );
354
+
355
+ server.tool(
356
+ "preview_tools",
357
+ "Return MCP-shaped tool definitions (name, description, inputSchema) for every operation in a spec — paste directly into your MCP host.",
358
+ { spec_or_url: z.string(), include_full_schema: z.boolean().default(true) },
359
+ async ({ spec_or_url, include_full_schema }) => {
360
+ const spec = /^https?:\/\//.test(spec_or_url) ? await fetchSpec(spec_or_url) : parseSpec(spec_or_url);
361
+ const tools = specToTools(spec).map((t) => ({
362
+ name: t.name,
363
+ description: t.description,
364
+ inputSchema: include_full_schema ? t.inputSchema : { type: "object", required: t.inputSchema.required },
365
+ _hint: `${t.method} ${t.path}`,
366
+ }));
367
+ return { content: [{ type: "text", text: JSON.stringify({ count: tools.length, tools }, null, 2) }] };
368
+ }
369
+ );
370
+
371
+ server.tool(
372
+ "generate_server",
373
+ "Emit a runnable Node MCP server (ESM) wrapping every operation. Set env OPENAPI_BASE_URL and OPENAPI_AUTH_HEADER at runtime.",
374
+ { spec_or_url: z.string(), name: z.string().optional(), base_url: z.string().optional() },
375
+ async ({ spec_or_url, name, base_url }) => {
376
+ const spec = /^https?:\/\//.test(spec_or_url) ? await fetchSpec(spec_or_url) : parseSpec(spec_or_url);
377
+ const code = generateServerCode(spec, { name, base_url });
378
+ return { content: [{ type: "text", text: code }] };
379
+ }
380
+ );
381
+
382
+ server.tool(
383
+ "call_endpoint",
384
+ "Fire one HTTP call against an operationId with validated args (path / query / body) — useful to test the bridge before generating a server.",
385
+ {
386
+ spec_or_url: z.string(),
387
+ operation_id: z.string(),
388
+ args: z.record(z.any()).default({}),
389
+ base_url: z.string().optional(),
390
+ auth_header: z.string().optional().describe('e.g. "Authorization: Bearer XYZ"'),
391
+ },
392
+ async ({ spec_or_url, operation_id, args, base_url, auth_header }) => {
393
+ const spec = /^https?:\/\//.test(spec_or_url) ? await fetchSpec(spec_or_url) : parseSpec(spec_or_url);
394
+ const out = await liveCall({ spec, base_url, auth_header, operation_id, args });
395
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
396
+ }
397
+ );
398
+
399
+ await server.connect(new StdioServerTransport());
package/mcpize.yaml ADDED
@@ -0,0 +1,12 @@
1
+ name: openapi-to-mcp-bridge-mcp
2
+ version: 1.0.0
3
+ description: Bridge any OpenAPI 3.x spec → MCP server in one call. Inspect tools, preview JSON Schemas, generate a runnable Node MCP server, or stream-call endpoints live without writing a wrapper.
4
+ license: MIT
5
+ runtime: node
6
+ entry: index.js
7
+ tools:
8
+ - from_url
9
+ - from_spec
10
+ - preview_tools
11
+ - generate_server
12
+ - call_endpoint
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "openapi-to-mcp-bridge-mcp",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "Bridge any OpenAPI 3.x spec → MCP server in one call. Inspect tools, preview JSON Schemas, generate a runnable Node MCP server, or stream-call endpoints live without writing a wrapper.",
6
+ "bin": { "openapi-to-mcp-bridge-mcp": "index.js" },
7
+ "main": "index.js",
8
+ "mcp": {
9
+ "tools": [
10
+ "from_url",
11
+ "from_spec",
12
+ "preview_tools",
13
+ "generate_server",
14
+ "call_endpoint"
15
+ ]
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "openapi",
20
+ "swagger",
21
+ "rest",
22
+ "bridge",
23
+ "codegen",
24
+ "agent"
25
+ ],
26
+ "license": "MIT",
27
+ "author": "lazymac2x",
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "latest",
30
+ "zod": "^3.23.0"
31
+ }
32
+ }