openapi-mcp-codegen 0.2.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/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/cli.js +1151 -0
- package/dist/generator/index.d.ts +3 -0
- package/dist/generator/index.d.ts.map +1 -0
- package/dist/generator/templates.d.ts +3 -0
- package/dist/generator/templates.d.ts.map +1 -0
- package/dist/index.cjs +1133 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1104 -0
- package/dist/index.js.map +1 -0
- package/dist/mapper/index.d.ts +3 -0
- package/dist/mapper/index.d.ts.map +1 -0
- package/dist/parser/index.d.ts +3 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/refs.d.ts +12 -0
- package/dist/parser/refs.d.ts.map +1 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +80 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
// src/parser/index.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { extname } from "path";
|
|
4
|
+
import { parse as parseYaml } from "yaml";
|
|
5
|
+
|
|
6
|
+
// src/parser/refs.ts
|
|
7
|
+
function resolveRefs(spec) {
|
|
8
|
+
const root = JSON.parse(JSON.stringify(spec));
|
|
9
|
+
const MAX_DEPTH = 64;
|
|
10
|
+
const cache = /* @__PURE__ */ new Map();
|
|
11
|
+
function lookup(ref) {
|
|
12
|
+
if (!ref.startsWith("#/")) return null;
|
|
13
|
+
const parts = ref.slice(2).split("/").map((p) => p.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
14
|
+
let cur = root;
|
|
15
|
+
for (const p of parts) {
|
|
16
|
+
if (cur === null || typeof cur !== "object") return null;
|
|
17
|
+
cur = cur[p];
|
|
18
|
+
}
|
|
19
|
+
return cur ?? null;
|
|
20
|
+
}
|
|
21
|
+
function resolve(node, stack, depth) {
|
|
22
|
+
if (node === null || typeof node !== "object") return node;
|
|
23
|
+
if (depth > MAX_DEPTH) {
|
|
24
|
+
return Array.isArray(node) ? [] : {};
|
|
25
|
+
}
|
|
26
|
+
if (Array.isArray(node)) {
|
|
27
|
+
return node.map((n) => resolve(n, stack, depth + 1));
|
|
28
|
+
}
|
|
29
|
+
const obj = node;
|
|
30
|
+
if (typeof obj.$ref === "string") {
|
|
31
|
+
const ref = obj.$ref;
|
|
32
|
+
if (stack.includes(ref)) {
|
|
33
|
+
return { type: "object", description: "(circular reference truncated)" };
|
|
34
|
+
}
|
|
35
|
+
if (cache.has(ref)) return cache.get(ref);
|
|
36
|
+
const target = lookup(ref);
|
|
37
|
+
if (target === null || target === void 0) {
|
|
38
|
+
const { $ref: _discard, ...rest } = obj;
|
|
39
|
+
return resolve(rest, stack, depth + 1);
|
|
40
|
+
}
|
|
41
|
+
const resolved = resolve(target, [...stack, ref], 0);
|
|
42
|
+
cache.set(ref, resolved);
|
|
43
|
+
return resolved;
|
|
44
|
+
}
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
47
|
+
out[k] = resolve(v, stack, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
return resolve(root, [], 0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/parser/index.ts
|
|
55
|
+
async function parseSpec(input) {
|
|
56
|
+
const raw = await loadRaw(input);
|
|
57
|
+
const spec = deserialize(raw, input);
|
|
58
|
+
validateSpec(spec);
|
|
59
|
+
return resolveRefs(spec);
|
|
60
|
+
}
|
|
61
|
+
async function loadRaw(input) {
|
|
62
|
+
if (input.startsWith("http://") || input.startsWith("https://")) {
|
|
63
|
+
const res = await fetch(input);
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
throw new Error(`Failed to fetch spec from ${input}: ${res.status} ${res.statusText}`);
|
|
66
|
+
}
|
|
67
|
+
return res.text();
|
|
68
|
+
}
|
|
69
|
+
return readFileSync(input, "utf-8");
|
|
70
|
+
}
|
|
71
|
+
function deserialize(raw, source) {
|
|
72
|
+
const ext = extname(source).toLowerCase();
|
|
73
|
+
if (ext === ".json") {
|
|
74
|
+
return JSON.parse(raw);
|
|
75
|
+
}
|
|
76
|
+
return parseYaml(raw);
|
|
77
|
+
}
|
|
78
|
+
function validateSpec(spec) {
|
|
79
|
+
const s = spec;
|
|
80
|
+
if (!s.openapi || typeof s.openapi !== "string") {
|
|
81
|
+
throw new Error("Invalid spec: missing 'openapi' version field");
|
|
82
|
+
}
|
|
83
|
+
if (!s.openapi.startsWith("3.")) {
|
|
84
|
+
throw new Error(`Unsupported OpenAPI version: ${s.openapi}. Only 3.x is supported.`);
|
|
85
|
+
}
|
|
86
|
+
if (!s.paths || typeof s.paths !== "object") {
|
|
87
|
+
throw new Error("Invalid spec: missing 'paths' object");
|
|
88
|
+
}
|
|
89
|
+
if (!s.info || typeof s.info !== "object") {
|
|
90
|
+
throw new Error("Invalid spec: missing 'info' object");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/mapper/index.ts
|
|
95
|
+
var HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
|
|
96
|
+
function mapToMcpTools(spec, options = {}) {
|
|
97
|
+
const tools = [];
|
|
98
|
+
const detectPagination = options.detectPagination ?? true;
|
|
99
|
+
const usedNames = /* @__PURE__ */ new Set();
|
|
100
|
+
for (const [path, pathItem] of Object.entries(spec.paths)) {
|
|
101
|
+
for (const method of HTTP_METHODS) {
|
|
102
|
+
const operation = pathItem[method];
|
|
103
|
+
if (!operation) continue;
|
|
104
|
+
if (operation["x-mcp-exclude"]) continue;
|
|
105
|
+
if (options.excludeTags?.length && operation.tags?.some((t) => options.excludeTags.includes(t))) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (options.includeTags?.length && !operation.tags?.some((t) => options.includeTags.includes(t))) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const tool = mapOperation(spec, path, method, operation, pathItem.parameters, detectPagination);
|
|
112
|
+
if (tool) {
|
|
113
|
+
if (options.excludeOperations?.includes(tool.name)) continue;
|
|
114
|
+
tool.name = uniqueName(tool.name, usedNames);
|
|
115
|
+
usedNames.add(tool.name);
|
|
116
|
+
tools.push(tool);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return tools;
|
|
121
|
+
}
|
|
122
|
+
function uniqueName(name, used) {
|
|
123
|
+
let base = name.slice(0, 60);
|
|
124
|
+
if (!used.has(base)) return base;
|
|
125
|
+
let i = 2;
|
|
126
|
+
while (used.has(`${base}_${i}`)) i++;
|
|
127
|
+
return `${base}_${i}`;
|
|
128
|
+
}
|
|
129
|
+
function mapOperation(spec, path, method, op, pathParams, detectPagination) {
|
|
130
|
+
const name = op["x-mcp-name"] ?? op.operationId ?? generateToolName(method, path);
|
|
131
|
+
const description = op["x-mcp-description"] ?? op.summary ?? op.description ?? `${method.toUpperCase()} ${path}`;
|
|
132
|
+
const allParams = mergeParams(pathParams, op.parameters);
|
|
133
|
+
const { schema: inputSchema, queryParams, bodyParams, bodyMode, bodyContentType } = buildInputSchema(op, allParams);
|
|
134
|
+
const auth = resolveAuth(spec, op);
|
|
135
|
+
const pagination = detectPagination ? detectPaginationConfig(op, allParams) : null;
|
|
136
|
+
const streaming = isStreamingResponse(op);
|
|
137
|
+
const responseHint = buildResponseHint(op);
|
|
138
|
+
if (pagination) {
|
|
139
|
+
inputSchema.properties = inputSchema.properties ?? {};
|
|
140
|
+
inputSchema.properties.cursor = {
|
|
141
|
+
type: "string",
|
|
142
|
+
description: "Opaque pagination cursor. Omit for first page."
|
|
143
|
+
};
|
|
144
|
+
inputSchema.properties.limit = {
|
|
145
|
+
type: "integer",
|
|
146
|
+
description: `Maximum items per page (default: ${pagination.defaultLimit})`
|
|
147
|
+
};
|
|
148
|
+
if (pagination.offsetParam) delete inputSchema.properties[pagination.offsetParam];
|
|
149
|
+
if (pagination.pageParam) delete inputSchema.properties[pagination.pageParam];
|
|
150
|
+
if (pagination.limitParam && pagination.limitParam !== "limit") {
|
|
151
|
+
delete inputSchema.properties[pagination.limitParam];
|
|
152
|
+
}
|
|
153
|
+
if (inputSchema.required) {
|
|
154
|
+
inputSchema.required = inputSchema.required.filter(
|
|
155
|
+
(r) => r !== pagination.offsetParam && r !== pagination.pageParam
|
|
156
|
+
);
|
|
157
|
+
if (inputSchema.required.length === 0) inputSchema.required = void 0;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const finalQueryParams = queryParams.filter(
|
|
161
|
+
(q) => !pagination || q !== pagination.offsetParam && q !== pagination.pageParam && q !== pagination.limitParam && q !== pagination.cursorParam
|
|
162
|
+
);
|
|
163
|
+
let fullDescription = pagination ? `${description} (paginated)` : description;
|
|
164
|
+
if (responseHint) fullDescription += responseHint;
|
|
165
|
+
return {
|
|
166
|
+
name: sanitizeName(name),
|
|
167
|
+
description: fullDescription,
|
|
168
|
+
inputSchema,
|
|
169
|
+
meta: {
|
|
170
|
+
method: method.toUpperCase(),
|
|
171
|
+
path,
|
|
172
|
+
tags: op.tags ?? [],
|
|
173
|
+
auth,
|
|
174
|
+
pagination,
|
|
175
|
+
streaming,
|
|
176
|
+
queryParams: finalQueryParams,
|
|
177
|
+
bodyParams,
|
|
178
|
+
bodyMode,
|
|
179
|
+
bodyContentType
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function buildResponseHint(op) {
|
|
184
|
+
const resp = op.responses?.["200"] ?? op.responses?.["201"];
|
|
185
|
+
const schema = resp?.content?.["application/json"]?.schema;
|
|
186
|
+
if (!schema) return null;
|
|
187
|
+
let fields = [];
|
|
188
|
+
let prefix = "Returns";
|
|
189
|
+
if (schema.type === "array") {
|
|
190
|
+
prefix = "Returns array of";
|
|
191
|
+
if (schema.items?.properties) fields = Object.keys(schema.items.properties);
|
|
192
|
+
} else if (schema.properties) {
|
|
193
|
+
fields = Object.keys(schema.properties);
|
|
194
|
+
}
|
|
195
|
+
if (fields.length === 0) return null;
|
|
196
|
+
const shown = fields.slice(0, 12);
|
|
197
|
+
const suffix = fields.length > shown.length ? ", ..." : "";
|
|
198
|
+
return ` ${prefix}: { ${shown.join(", ")}${suffix} }`;
|
|
199
|
+
}
|
|
200
|
+
function mergeParams(pathParams, opParams) {
|
|
201
|
+
const allParams = [...pathParams ?? [], ...opParams ?? []];
|
|
202
|
+
const seen = /* @__PURE__ */ new Set();
|
|
203
|
+
const result = [];
|
|
204
|
+
for (const p of allParams.reverse()) {
|
|
205
|
+
const key = `${p.in}:${p.name}`;
|
|
206
|
+
if (!seen.has(key)) {
|
|
207
|
+
seen.add(key);
|
|
208
|
+
result.push(p);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
function buildInputSchema(op, params) {
|
|
214
|
+
const properties = {};
|
|
215
|
+
const required = [];
|
|
216
|
+
const queryParams = [];
|
|
217
|
+
const bodyParams = [];
|
|
218
|
+
let bodyMode = "none";
|
|
219
|
+
for (const param of params) {
|
|
220
|
+
if (param.in === "header" || param.in === "cookie") continue;
|
|
221
|
+
properties[param.name] = {
|
|
222
|
+
...param.schema,
|
|
223
|
+
description: param.description ?? param.schema?.description
|
|
224
|
+
};
|
|
225
|
+
if (param.in === "query") queryParams.push(param.name);
|
|
226
|
+
if (param.required) {
|
|
227
|
+
required.push(param.name);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const content = op.requestBody?.content;
|
|
231
|
+
const bodySchema = content?.["application/json"]?.schema ?? content?.["application/x-www-form-urlencoded"]?.schema;
|
|
232
|
+
const bodyContentType = !content?.["application/json"] && content?.["application/x-www-form-urlencoded"] ? "form" : "json";
|
|
233
|
+
if (bodySchema) {
|
|
234
|
+
if (bodySchema.type === "object" && bodySchema.properties) {
|
|
235
|
+
bodyMode = "fields";
|
|
236
|
+
for (const [key, val] of Object.entries(bodySchema.properties)) {
|
|
237
|
+
let propKey = key;
|
|
238
|
+
if (propKey in properties) propKey = `body_${key}`;
|
|
239
|
+
properties[propKey] = val;
|
|
240
|
+
bodyParams.push([propKey, key]);
|
|
241
|
+
if (bodySchema.required?.includes(key)) {
|
|
242
|
+
required.push(propKey);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
bodyMode = "whole";
|
|
247
|
+
let bodyKey = "body";
|
|
248
|
+
if (bodyKey in properties) bodyKey = "request_body";
|
|
249
|
+
properties[bodyKey] = {
|
|
250
|
+
...bodySchema,
|
|
251
|
+
description: op.requestBody?.description ?? bodySchema.description
|
|
252
|
+
};
|
|
253
|
+
bodyParams.push([bodyKey, "*"]);
|
|
254
|
+
if (op.requestBody?.required) {
|
|
255
|
+
required.push(bodyKey);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
schema: {
|
|
261
|
+
type: "object",
|
|
262
|
+
properties,
|
|
263
|
+
required: required.length > 0 ? required : void 0
|
|
264
|
+
},
|
|
265
|
+
queryParams,
|
|
266
|
+
bodyParams,
|
|
267
|
+
bodyMode,
|
|
268
|
+
bodyContentType
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
function detectPaginationConfig(op, params) {
|
|
272
|
+
const hint = op["x-mcp-pagination"];
|
|
273
|
+
if (hint) {
|
|
274
|
+
return {
|
|
275
|
+
type: hint.type,
|
|
276
|
+
limitParam: hint.limitParam ?? "limit",
|
|
277
|
+
offsetParam: hint.offsetParam,
|
|
278
|
+
pageParam: hint.pageParam,
|
|
279
|
+
cursorParam: hint.cursorParam,
|
|
280
|
+
cursorPath: hint.cursorPath ?? "next_cursor",
|
|
281
|
+
totalPath: hint.totalPath ?? "total",
|
|
282
|
+
itemsPath: hint.itemsPath ?? "items",
|
|
283
|
+
defaultLimit: 20
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const paramNames = params.map((p) => p.name.toLowerCase());
|
|
287
|
+
const hasLimit = paramNames.some((n) => n === "limit" || n === "per_page" || n === "page_size" || n === "pagesize");
|
|
288
|
+
if (!hasLimit) return null;
|
|
289
|
+
const limitParam = params.find((p) => {
|
|
290
|
+
const n = p.name.toLowerCase();
|
|
291
|
+
return n === "limit" || n === "per_page" || n === "page_size" || n === "pagesize";
|
|
292
|
+
});
|
|
293
|
+
const cursorParam = params.find((p) => {
|
|
294
|
+
const n = p.name.toLowerCase();
|
|
295
|
+
return n === "cursor" || n === "after" || n === "next_token" || n === "page_token" || n === "starting_after";
|
|
296
|
+
});
|
|
297
|
+
if (cursorParam) {
|
|
298
|
+
return {
|
|
299
|
+
type: "cursor",
|
|
300
|
+
limitParam: limitParam.name,
|
|
301
|
+
cursorParam: cursorParam.name,
|
|
302
|
+
cursorPath: "next_cursor",
|
|
303
|
+
totalPath: "total",
|
|
304
|
+
itemsPath: "items",
|
|
305
|
+
defaultLimit: 20
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
const offsetParam = params.find((p) => {
|
|
309
|
+
const n = p.name.toLowerCase();
|
|
310
|
+
return n === "offset" || n === "skip";
|
|
311
|
+
});
|
|
312
|
+
if (offsetParam) {
|
|
313
|
+
return {
|
|
314
|
+
type: "offset",
|
|
315
|
+
limitParam: limitParam.name,
|
|
316
|
+
offsetParam: offsetParam.name,
|
|
317
|
+
cursorPath: "next_cursor",
|
|
318
|
+
totalPath: "total",
|
|
319
|
+
itemsPath: "items",
|
|
320
|
+
defaultLimit: 20
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const pageParam = params.find((p) => {
|
|
324
|
+
const n = p.name.toLowerCase();
|
|
325
|
+
return n === "page" || n === "page_number" || n === "pagenumber";
|
|
326
|
+
});
|
|
327
|
+
if (pageParam) {
|
|
328
|
+
return {
|
|
329
|
+
type: "page",
|
|
330
|
+
limitParam: limitParam.name,
|
|
331
|
+
pageParam: pageParam.name,
|
|
332
|
+
cursorPath: "next_cursor",
|
|
333
|
+
totalPath: "total",
|
|
334
|
+
itemsPath: "items",
|
|
335
|
+
defaultLimit: 20
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
function isStreamingResponse(op) {
|
|
341
|
+
if (!op.responses) return false;
|
|
342
|
+
const successResponse = op.responses["200"] ?? op.responses["201"];
|
|
343
|
+
if (!successResponse?.content) return false;
|
|
344
|
+
return "text/event-stream" in successResponse.content || "application/x-ndjson" in successResponse.content || "application/stream+json" in successResponse.content;
|
|
345
|
+
}
|
|
346
|
+
function resolveAuth(spec, op) {
|
|
347
|
+
const securityReqs = op.security ?? spec.security ?? [];
|
|
348
|
+
if (securityReqs.length === 0) return null;
|
|
349
|
+
const schemeName = Object.keys(securityReqs[0])[0];
|
|
350
|
+
if (!schemeName) return null;
|
|
351
|
+
return spec.components?.securitySchemes?.[schemeName] ?? null;
|
|
352
|
+
}
|
|
353
|
+
function generateToolName(method, path) {
|
|
354
|
+
const segments = path.split("/").filter(Boolean).map((s) => {
|
|
355
|
+
if (s.startsWith("{") && s.endsWith("}")) {
|
|
356
|
+
return `by_${s.slice(1, -1)}`;
|
|
357
|
+
}
|
|
358
|
+
return s;
|
|
359
|
+
});
|
|
360
|
+
return `${method}_${segments.join("_")}`;
|
|
361
|
+
}
|
|
362
|
+
function sanitizeName(name) {
|
|
363
|
+
return name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/_+/g, "_");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/generator/index.ts
|
|
367
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
368
|
+
import { join } from "path";
|
|
369
|
+
|
|
370
|
+
// src/generator/templates.ts
|
|
371
|
+
function renderServerCode(opts) {
|
|
372
|
+
const imports = buildImports(opts);
|
|
373
|
+
const toolRegistrations = opts.tools.map((t) => renderToolHandler(t, opts.auth)).join("\n\n");
|
|
374
|
+
const serverSetup = buildServerSetup(opts);
|
|
375
|
+
return `${imports}
|
|
376
|
+
|
|
377
|
+
const server = new McpServer({
|
|
378
|
+
name: ${JSON.stringify(opts.serverName)},
|
|
379
|
+
version: "0.1.0",
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const API_BASE_URL = process.env.API_BASE_URL ?? ${JSON.stringify(opts.spec.servers?.[0]?.url ?? "")};
|
|
383
|
+
|
|
384
|
+
${renderAuthHelper(opts)}
|
|
385
|
+
|
|
386
|
+
${renderPaginationHelper(opts)}
|
|
387
|
+
|
|
388
|
+
${renderRequestHelpers(opts)}
|
|
389
|
+
|
|
390
|
+
${toolRegistrations}
|
|
391
|
+
|
|
392
|
+
${serverSetup}
|
|
393
|
+
`;
|
|
394
|
+
}
|
|
395
|
+
function buildServerSetup(opts) {
|
|
396
|
+
if (opts.transport === "stdio") {
|
|
397
|
+
return `async function main() {
|
|
398
|
+
const transport = new StdioServerTransport();
|
|
399
|
+
await server.connect(transport);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
main().catch(console.error);`;
|
|
403
|
+
}
|
|
404
|
+
return `const PORT = parseInt(process.env.PORT ?? "3000", 10);
|
|
405
|
+
|
|
406
|
+
async function main() {
|
|
407
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
408
|
+
await server.connect(transport);
|
|
409
|
+
|
|
410
|
+
const httpServer = createServer(async (req, res) => {
|
|
411
|
+
const url = new URL(req.url ?? "/", \`http://\${req.headers.host}\`);
|
|
412
|
+
if (url.pathname === "/mcp") {
|
|
413
|
+
try {
|
|
414
|
+
await transport.handleRequest(req, res);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
console.error("Error handling MCP request:", err);
|
|
417
|
+
if (!res.headersSent) {
|
|
418
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
419
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null }));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
res.writeHead(404);
|
|
424
|
+
res.end("Not found");
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
httpServer.listen(PORT, () => {
|
|
429
|
+
console.log(\`MCP server listening on http://localhost:\${PORT}/mcp\`);
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
main().catch(console.error);`;
|
|
434
|
+
}
|
|
435
|
+
function buildImports(opts) {
|
|
436
|
+
const lines = [
|
|
437
|
+
`import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";`
|
|
438
|
+
];
|
|
439
|
+
if (opts.transport === "stdio") {
|
|
440
|
+
lines.push(
|
|
441
|
+
`import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";`
|
|
442
|
+
);
|
|
443
|
+
} else {
|
|
444
|
+
lines.push(
|
|
445
|
+
`import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";`,
|
|
446
|
+
`import { createServer } from "node:http";`
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
lines.push(`import { z } from "zod";`);
|
|
450
|
+
if (opts.auth === "oauth2-auth-code") {
|
|
451
|
+
lines.push(
|
|
452
|
+
`import { createServer as createHttpServer } from "node:http";`,
|
|
453
|
+
`import { randomBytes, createHash } from "node:crypto";`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
return lines.join("\n");
|
|
457
|
+
}
|
|
458
|
+
function renderAuthHelper(opts) {
|
|
459
|
+
if (opts.auth === "none") {
|
|
460
|
+
return `function authHeaders(): Record<string, string> {
|
|
461
|
+
return {};
|
|
462
|
+
}`;
|
|
463
|
+
}
|
|
464
|
+
if (opts.auth === "api-key") {
|
|
465
|
+
const apiKeyScheme = Object.values(opts.spec.components?.securitySchemes ?? {}).find((s) => s.type === "apiKey");
|
|
466
|
+
const headerName = apiKeyScheme?.name ?? "X-API-Key";
|
|
467
|
+
return `function authHeaders(): Record<string, string> {
|
|
468
|
+
const key = process.env.API_KEY;
|
|
469
|
+
if (!key) throw new Error("API_KEY environment variable is required");
|
|
470
|
+
return { ${JSON.stringify(headerName)}: key };
|
|
471
|
+
}`;
|
|
472
|
+
}
|
|
473
|
+
if (opts.auth === "bearer") {
|
|
474
|
+
return `function authHeaders(): Record<string, string> {
|
|
475
|
+
const token = process.env.API_TOKEN;
|
|
476
|
+
if (!token) throw new Error("API_TOKEN environment variable is required");
|
|
477
|
+
return { "Authorization": \`Bearer \${token}\` };
|
|
478
|
+
}`;
|
|
479
|
+
}
|
|
480
|
+
if (opts.auth === "oauth2-auth-code") {
|
|
481
|
+
return renderOAuth2AuthCodeHelper(opts);
|
|
482
|
+
}
|
|
483
|
+
return renderOAuth2CCHelper();
|
|
484
|
+
}
|
|
485
|
+
function renderOAuth2CCHelper() {
|
|
486
|
+
return `let cachedToken: { token: string; expiresAt: number } | null = null;
|
|
487
|
+
|
|
488
|
+
async function getOAuthToken(): Promise<string> {
|
|
489
|
+
if (cachedToken && Date.now() < cachedToken.expiresAt) {
|
|
490
|
+
return cachedToken.token;
|
|
491
|
+
}
|
|
492
|
+
const tokenUrl = process.env.OAUTH_TOKEN_URL;
|
|
493
|
+
const clientId = process.env.OAUTH_CLIENT_ID;
|
|
494
|
+
const clientSecret = process.env.OAUTH_CLIENT_SECRET;
|
|
495
|
+
if (!tokenUrl || !clientId || !clientSecret) {
|
|
496
|
+
throw new Error("OAUTH_TOKEN_URL, OAUTH_CLIENT_ID, and OAUTH_CLIENT_SECRET are required");
|
|
497
|
+
}
|
|
498
|
+
const res = await fetch(tokenUrl, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
501
|
+
body: new URLSearchParams({
|
|
502
|
+
grant_type: "client_credentials",
|
|
503
|
+
client_id: clientId,
|
|
504
|
+
client_secret: clientSecret,
|
|
505
|
+
}),
|
|
506
|
+
});
|
|
507
|
+
if (!res.ok) throw new Error(\`OAuth token request failed: \${res.status}\`);
|
|
508
|
+
const data = await res.json() as { access_token: string; expires_in?: number };
|
|
509
|
+
cachedToken = {
|
|
510
|
+
token: data.access_token,
|
|
511
|
+
expiresAt: Date.now() + ((data.expires_in ?? 3600) - 60) * 1000,
|
|
512
|
+
};
|
|
513
|
+
return cachedToken.token;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function authHeaders(): Promise<Record<string, string>> {
|
|
517
|
+
const token = await getOAuthToken();
|
|
518
|
+
return { "Authorization": \`Bearer \${token}\` };
|
|
519
|
+
}`;
|
|
520
|
+
}
|
|
521
|
+
function renderOAuth2AuthCodeHelper(opts) {
|
|
522
|
+
const oauthScheme = Object.values(opts.spec.components?.securitySchemes ?? {}).find((s) => s.type === "oauth2");
|
|
523
|
+
const flow = oauthScheme?.flows?.authorizationCode;
|
|
524
|
+
const defaultAuthUrl = flow?.authorizationUrl ?? "";
|
|
525
|
+
const defaultTokenUrl = flow?.tokenUrl ?? "";
|
|
526
|
+
const defaultScopes = Object.keys(flow?.scopes ?? {}).join(" ");
|
|
527
|
+
return `interface TokenStore {
|
|
528
|
+
accessToken: string;
|
|
529
|
+
refreshToken?: string;
|
|
530
|
+
expiresAt: number;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
let tokenStore: TokenStore | null = null;
|
|
534
|
+
|
|
535
|
+
const OAUTH_AUTH_URL = process.env.OAUTH_AUTH_URL ?? ${JSON.stringify(defaultAuthUrl)};
|
|
536
|
+
const OAUTH_TOKEN_URL = process.env.OAUTH_TOKEN_URL ?? ${JSON.stringify(defaultTokenUrl)};
|
|
537
|
+
const OAUTH_CLIENT_ID = process.env.OAUTH_CLIENT_ID ?? "";
|
|
538
|
+
const OAUTH_CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET ?? "";
|
|
539
|
+
const OAUTH_REDIRECT_PORT = parseInt(process.env.OAUTH_REDIRECT_PORT ?? "8976", 10);
|
|
540
|
+
const OAUTH_REDIRECT_URI = \`http://localhost:\${OAUTH_REDIRECT_PORT}/callback\`;
|
|
541
|
+
const OAUTH_SCOPES = process.env.OAUTH_SCOPES ?? ${JSON.stringify(defaultScopes)};
|
|
542
|
+
|
|
543
|
+
function generatePKCE(): { verifier: string; challenge: string } {
|
|
544
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
545
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
546
|
+
return { verifier, challenge };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function refreshAccessToken(): Promise<string> {
|
|
550
|
+
if (!tokenStore?.refreshToken) throw new Error("No refresh token available");
|
|
551
|
+
const res = await fetch(OAUTH_TOKEN_URL, {
|
|
552
|
+
method: "POST",
|
|
553
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
554
|
+
body: new URLSearchParams({
|
|
555
|
+
grant_type: "refresh_token",
|
|
556
|
+
refresh_token: tokenStore.refreshToken,
|
|
557
|
+
client_id: OAUTH_CLIENT_ID,
|
|
558
|
+
...(OAUTH_CLIENT_SECRET ? { client_secret: OAUTH_CLIENT_SECRET } : {}),
|
|
559
|
+
}),
|
|
560
|
+
});
|
|
561
|
+
if (!res.ok) {
|
|
562
|
+
tokenStore = null;
|
|
563
|
+
throw new Error(\`Token refresh failed: \${res.status}\`);
|
|
564
|
+
}
|
|
565
|
+
const data = await res.json() as {
|
|
566
|
+
access_token: string;
|
|
567
|
+
refresh_token?: string;
|
|
568
|
+
expires_in?: number;
|
|
569
|
+
};
|
|
570
|
+
tokenStore = {
|
|
571
|
+
accessToken: data.access_token,
|
|
572
|
+
refreshToken: data.refresh_token ?? tokenStore.refreshToken,
|
|
573
|
+
expiresAt: Date.now() + ((data.expires_in ?? 3600) - 60) * 1000,
|
|
574
|
+
};
|
|
575
|
+
return tokenStore.accessToken;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function authorizeInteractive(): Promise<string> {
|
|
579
|
+
if (!OAUTH_CLIENT_ID) throw new Error("OAUTH_CLIENT_ID is required");
|
|
580
|
+
if (!OAUTH_AUTH_URL || !OAUTH_TOKEN_URL) {
|
|
581
|
+
throw new Error("OAUTH_AUTH_URL and OAUTH_TOKEN_URL are required");
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const { verifier, challenge } = generatePKCE();
|
|
585
|
+
const state = randomBytes(16).toString("hex");
|
|
586
|
+
|
|
587
|
+
const authUrl = new URL(OAUTH_AUTH_URL);
|
|
588
|
+
authUrl.searchParams.set("response_type", "code");
|
|
589
|
+
authUrl.searchParams.set("client_id", OAUTH_CLIENT_ID);
|
|
590
|
+
authUrl.searchParams.set("redirect_uri", OAUTH_REDIRECT_URI);
|
|
591
|
+
authUrl.searchParams.set("state", state);
|
|
592
|
+
authUrl.searchParams.set("code_challenge", challenge);
|
|
593
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
594
|
+
if (OAUTH_SCOPES) authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
|
595
|
+
|
|
596
|
+
console.error(\`\\nOpen this URL to authorize:\\n\${authUrl.toString()}\\n\`);
|
|
597
|
+
|
|
598
|
+
const code = await new Promise<string>((resolve, reject) => {
|
|
599
|
+
const srv = createHttpServer((req, res) => {
|
|
600
|
+
const url = new URL(req.url ?? "/", \`http://localhost:\${OAUTH_REDIRECT_PORT}\`);
|
|
601
|
+
if (url.pathname !== "/callback") {
|
|
602
|
+
res.writeHead(404);
|
|
603
|
+
res.end();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const receivedState = url.searchParams.get("state");
|
|
607
|
+
const receivedCode = url.searchParams.get("code");
|
|
608
|
+
const error = url.searchParams.get("error");
|
|
609
|
+
|
|
610
|
+
if (error) {
|
|
611
|
+
res.writeHead(400);
|
|
612
|
+
res.end(\`Authorization error: \${error}\`);
|
|
613
|
+
srv.close();
|
|
614
|
+
reject(new Error(\`OAuth error: \${error}\`));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (receivedState !== state || !receivedCode) {
|
|
618
|
+
res.writeHead(400);
|
|
619
|
+
res.end("Invalid callback");
|
|
620
|
+
srv.close();
|
|
621
|
+
reject(new Error("Invalid OAuth callback"));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
626
|
+
res.end("<h1>Authorization successful!</h1><p>You can close this tab.</p>");
|
|
627
|
+
srv.close();
|
|
628
|
+
resolve(receivedCode);
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
srv.listen(OAUTH_REDIRECT_PORT, () => {
|
|
632
|
+
console.error(\`Waiting for OAuth callback on port \${OAUTH_REDIRECT_PORT}...\`);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
setTimeout(() => {
|
|
636
|
+
srv.close();
|
|
637
|
+
reject(new Error("OAuth callback timed out after 120 seconds"));
|
|
638
|
+
}, 120_000);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const tokenRes = await fetch(OAUTH_TOKEN_URL, {
|
|
642
|
+
method: "POST",
|
|
643
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
644
|
+
body: new URLSearchParams({
|
|
645
|
+
grant_type: "authorization_code",
|
|
646
|
+
code,
|
|
647
|
+
redirect_uri: OAUTH_REDIRECT_URI,
|
|
648
|
+
client_id: OAUTH_CLIENT_ID,
|
|
649
|
+
code_verifier: verifier,
|
|
650
|
+
...(OAUTH_CLIENT_SECRET ? { client_secret: OAUTH_CLIENT_SECRET } : {}),
|
|
651
|
+
}),
|
|
652
|
+
});
|
|
653
|
+
if (!tokenRes.ok) throw new Error(\`Token exchange failed: \${tokenRes.status}\`);
|
|
654
|
+
|
|
655
|
+
const tokenData = await tokenRes.json() as {
|
|
656
|
+
access_token: string;
|
|
657
|
+
refresh_token?: string;
|
|
658
|
+
expires_in?: number;
|
|
659
|
+
};
|
|
660
|
+
tokenStore = {
|
|
661
|
+
accessToken: tokenData.access_token,
|
|
662
|
+
refreshToken: tokenData.refresh_token,
|
|
663
|
+
expiresAt: Date.now() + ((tokenData.expires_in ?? 3600) - 60) * 1000,
|
|
664
|
+
};
|
|
665
|
+
return tokenStore.accessToken;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function authHeaders(): Promise<Record<string, string>> {
|
|
669
|
+
if (tokenStore && Date.now() < tokenStore.expiresAt) {
|
|
670
|
+
return { "Authorization": \`Bearer \${tokenStore.accessToken}\` };
|
|
671
|
+
}
|
|
672
|
+
if (tokenStore?.refreshToken) {
|
|
673
|
+
try {
|
|
674
|
+
const token = await refreshAccessToken();
|
|
675
|
+
return { "Authorization": \`Bearer \${token}\` };
|
|
676
|
+
} catch {
|
|
677
|
+
// Refresh failed, re-authorize
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const token = await authorizeInteractive();
|
|
681
|
+
return { "Authorization": \`Bearer \${token}\` };
|
|
682
|
+
}`;
|
|
683
|
+
}
|
|
684
|
+
function renderPaginationHelper(opts) {
|
|
685
|
+
const hasPaginated = opts.tools.some((t) => t.meta.pagination);
|
|
686
|
+
if (!hasPaginated) return "";
|
|
687
|
+
return `// Pagination cursor encoding/decoding
|
|
688
|
+
function encodeCursor(data: Record<string, unknown>): string {
|
|
689
|
+
return Buffer.from(JSON.stringify(data)).toString("base64url");
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function decodeCursor(cursor: string): Record<string, unknown> {
|
|
693
|
+
try {
|
|
694
|
+
return JSON.parse(Buffer.from(cursor, "base64url").toString("utf-8"));
|
|
695
|
+
} catch {
|
|
696
|
+
throw new Error("Invalid pagination cursor");
|
|
697
|
+
}
|
|
698
|
+
}`;
|
|
699
|
+
}
|
|
700
|
+
function renderToolHandler(tool, authMode) {
|
|
701
|
+
if (tool.meta.pagination) {
|
|
702
|
+
return renderPaginatedToolHandler(tool, authMode);
|
|
703
|
+
}
|
|
704
|
+
if (tool.meta.streaming) {
|
|
705
|
+
return renderStreamingToolHandler(tool, authMode);
|
|
706
|
+
}
|
|
707
|
+
return renderSimpleToolHandler(tool, authMode);
|
|
708
|
+
}
|
|
709
|
+
function renderSimpleToolHandler(tool, authMode) {
|
|
710
|
+
const zodSchema = jsonSchemaToZod(tool.inputSchema);
|
|
711
|
+
const authCall = isAsyncAuth(authMode) ? "await authHeaders()" : "authHeaders()";
|
|
712
|
+
const pathInterpolation = interpolatePath(tool.meta.path);
|
|
713
|
+
const query = tool.meta.queryParams.length ? ` + buildQuery(p, ${JSON.stringify(tool.meta.queryParams)})` : "";
|
|
714
|
+
const hasBody = tool.meta.bodyMode !== "none" && tool.meta.method !== "GET";
|
|
715
|
+
const isForm = tool.meta.bodyContentType === "form";
|
|
716
|
+
const pickCall = `pickBody(p, ${JSON.stringify(tool.meta.bodyParams)})`;
|
|
717
|
+
const bodyLine = hasBody ? `
|
|
718
|
+
body: ${isForm ? `formEncode(${pickCall}).toString()` : `JSON.stringify(${pickCall})`},` : "";
|
|
719
|
+
const contentType = hasBody && isForm ? "application/x-www-form-urlencoded" : "application/json";
|
|
720
|
+
return `server.tool(
|
|
721
|
+
${JSON.stringify(tool.name)},
|
|
722
|
+
${JSON.stringify(tool.description)},
|
|
723
|
+
${zodSchema},
|
|
724
|
+
async (params) => {
|
|
725
|
+
const p: Record<string, unknown> = params;
|
|
726
|
+
const url = \`\${API_BASE_URL}${pathInterpolation}\`${query};
|
|
727
|
+
const headers: Record<string, string> = { ...${authCall}, "Content-Type": ${JSON.stringify(contentType)} };
|
|
728
|
+
const res = await fetch(url, {
|
|
729
|
+
method: ${JSON.stringify(tool.meta.method)},
|
|
730
|
+
headers,${bodyLine}
|
|
731
|
+
});
|
|
732
|
+
const data = await res.text();
|
|
733
|
+
return {
|
|
734
|
+
content: [{ type: "text" as const, text: data }],
|
|
735
|
+
};
|
|
736
|
+
},
|
|
737
|
+
);`;
|
|
738
|
+
}
|
|
739
|
+
function renderPaginatedToolHandler(tool, authMode) {
|
|
740
|
+
const zodSchema = jsonSchemaToZod(tool.inputSchema);
|
|
741
|
+
const authCall = isAsyncAuth(authMode) ? "await authHeaders()" : "authHeaders()";
|
|
742
|
+
const pg = tool.meta.pagination;
|
|
743
|
+
const pathInterpolation = interpolatePath(tool.meta.path);
|
|
744
|
+
const passThrough = JSON.stringify(tool.meta.queryParams);
|
|
745
|
+
let paginationLogic;
|
|
746
|
+
if (pg.type === "offset") {
|
|
747
|
+
paginationLogic = ` // Decode cursor to get offset, or start from 0
|
|
748
|
+
const pageLimit = typeof p.limit === "number" ? p.limit : ${pg.defaultLimit};
|
|
749
|
+
let offset = 0;
|
|
750
|
+
if (typeof p.cursor === "string") {
|
|
751
|
+
const decoded = decodeCursor(p.cursor);
|
|
752
|
+
offset = (decoded.offset as number) ?? 0;
|
|
753
|
+
}
|
|
754
|
+
const queryParams: Record<string, string> = {};
|
|
755
|
+
for (const k of ${passThrough}) {
|
|
756
|
+
const v = p[k];
|
|
757
|
+
if (v !== undefined && v !== null) queryParams[k] = String(v);
|
|
758
|
+
}
|
|
759
|
+
queryParams[${JSON.stringify(pg.limitParam)}] = String(pageLimit);
|
|
760
|
+
queryParams[${JSON.stringify(pg.offsetParam)}] = String(offset);
|
|
761
|
+
const qs = new URLSearchParams(queryParams).toString();
|
|
762
|
+
const url = \`\${API_BASE_URL}${pathInterpolation}\` + (qs ? "?" + qs : "");`;
|
|
763
|
+
} else if (pg.type === "page") {
|
|
764
|
+
paginationLogic = ` // Decode cursor to get page number, or start from 1
|
|
765
|
+
const pageLimit = typeof p.limit === "number" ? p.limit : ${pg.defaultLimit};
|
|
766
|
+
let page = 1;
|
|
767
|
+
if (typeof p.cursor === "string") {
|
|
768
|
+
const decoded = decodeCursor(p.cursor);
|
|
769
|
+
page = (decoded.page as number) ?? 1;
|
|
770
|
+
}
|
|
771
|
+
const queryParams: Record<string, string> = {};
|
|
772
|
+
for (const k of ${passThrough}) {
|
|
773
|
+
const v = p[k];
|
|
774
|
+
if (v !== undefined && v !== null) queryParams[k] = String(v);
|
|
775
|
+
}
|
|
776
|
+
queryParams[${JSON.stringify(pg.limitParam)}] = String(pageLimit);
|
|
777
|
+
queryParams[${JSON.stringify(pg.pageParam)}] = String(page);
|
|
778
|
+
const qs = new URLSearchParams(queryParams).toString();
|
|
779
|
+
const url = \`\${API_BASE_URL}${pathInterpolation}\` + (qs ? "?" + qs : "");`;
|
|
780
|
+
} else {
|
|
781
|
+
paginationLogic = ` const pageLimit = typeof p.limit === "number" ? p.limit : ${pg.defaultLimit};
|
|
782
|
+
const queryParams: Record<string, string> = {};
|
|
783
|
+
for (const k of ${passThrough}) {
|
|
784
|
+
const v = p[k];
|
|
785
|
+
if (v !== undefined && v !== null) queryParams[k] = String(v);
|
|
786
|
+
}
|
|
787
|
+
queryParams[${JSON.stringify(pg.limitParam)}] = String(pageLimit);
|
|
788
|
+
if (typeof p.cursor === "string") {
|
|
789
|
+
const decoded = decodeCursor(p.cursor);
|
|
790
|
+
queryParams[${JSON.stringify(pg.cursorParam ?? "cursor")}] = decoded.cursor as string;
|
|
791
|
+
}
|
|
792
|
+
const qs = new URLSearchParams(queryParams).toString();
|
|
793
|
+
const url = \`\${API_BASE_URL}${pathInterpolation}\` + (qs ? "?" + qs : "");`;
|
|
794
|
+
}
|
|
795
|
+
let nextCursorLogic;
|
|
796
|
+
if (pg.type === "offset") {
|
|
797
|
+
nextCursorLogic = ` const nextOffset = offset + pageLimit;
|
|
798
|
+
const total = parsed?.${pg.totalPath} as number | undefined;
|
|
799
|
+
const hasMore = total !== undefined ? nextOffset < total : items.length >= pageLimit;
|
|
800
|
+
const nextCursor = hasMore ? encodeCursor({ offset: nextOffset }) : null;`;
|
|
801
|
+
} else if (pg.type === "page") {
|
|
802
|
+
nextCursorLogic = ` const total = parsed?.${pg.totalPath} as number | undefined;
|
|
803
|
+
const totalPages = total !== undefined ? Math.ceil(total / pageLimit) : undefined;
|
|
804
|
+
const hasMore = totalPages !== undefined ? page < totalPages : items.length >= pageLimit;
|
|
805
|
+
const nextCursor = hasMore ? encodeCursor({ page: page + 1 }) : null;`;
|
|
806
|
+
} else {
|
|
807
|
+
nextCursorLogic = ` const apiCursor = parsed?.${pg.cursorPath} as string | undefined;
|
|
808
|
+
const hasMore = !!apiCursor;
|
|
809
|
+
const nextCursor = hasMore ? encodeCursor({ cursor: apiCursor }) : null;`;
|
|
810
|
+
}
|
|
811
|
+
return `server.tool(
|
|
812
|
+
${JSON.stringify(tool.name)},
|
|
813
|
+
${JSON.stringify(tool.description)},
|
|
814
|
+
${zodSchema},
|
|
815
|
+
async (params) => {
|
|
816
|
+
const p: Record<string, unknown> = params;
|
|
817
|
+
${paginationLogic}
|
|
818
|
+
const headers: Record<string, string> = { ...${authCall}, "Content-Type": "application/json" };
|
|
819
|
+
const res = await fetch(url, { method: "GET", headers });
|
|
820
|
+
const data = await res.text();
|
|
821
|
+
let parsed: Record<string, unknown> | null = null;
|
|
822
|
+
try { parsed = JSON.parse(data); } catch {}
|
|
823
|
+
const items = (parsed && Array.isArray(parsed.${pg.itemsPath}) ? parsed.${pg.itemsPath} : parsed) as unknown[];
|
|
824
|
+
${nextCursorLogic}
|
|
825
|
+
const result = {
|
|
826
|
+
items,
|
|
827
|
+
has_more: hasMore,
|
|
828
|
+
...(nextCursor ? { next_cursor: nextCursor } : {}),
|
|
829
|
+
...(parsed?.${pg.totalPath} !== undefined ? { total: parsed.${pg.totalPath} } : {}),
|
|
830
|
+
};
|
|
831
|
+
return {
|
|
832
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
833
|
+
};
|
|
834
|
+
},
|
|
835
|
+
);`;
|
|
836
|
+
}
|
|
837
|
+
function renderStreamingToolHandler(tool, authMode) {
|
|
838
|
+
const zodSchema = jsonSchemaToZod(tool.inputSchema);
|
|
839
|
+
const authCall = isAsyncAuth(authMode) ? "await authHeaders()" : "authHeaders()";
|
|
840
|
+
const pathInterpolation = interpolatePath(tool.meta.path);
|
|
841
|
+
const query = tool.meta.queryParams.length ? ` + buildQuery(p, ${JSON.stringify(tool.meta.queryParams)})` : "";
|
|
842
|
+
return `server.tool(
|
|
843
|
+
${JSON.stringify(tool.name)},
|
|
844
|
+
${JSON.stringify(tool.description + " (streaming)")},
|
|
845
|
+
${zodSchema},
|
|
846
|
+
async (params) => {
|
|
847
|
+
const p: Record<string, unknown> = params;
|
|
848
|
+
const url = \`\${API_BASE_URL}${pathInterpolation}\`${query};
|
|
849
|
+
const headers: Record<string, string> = { ...${authCall}, "Accept": "text/event-stream" };
|
|
850
|
+
const res = await fetch(url, { method: ${JSON.stringify(tool.meta.method)}, headers });
|
|
851
|
+
if (!res.body) {
|
|
852
|
+
return { content: [{ type: "text" as const, text: "No response body" }] };
|
|
853
|
+
}
|
|
854
|
+
const reader = res.body.getReader();
|
|
855
|
+
const decoder = new TextDecoder();
|
|
856
|
+
const chunks: string[] = [];
|
|
857
|
+
while (true) {
|
|
858
|
+
const { done, value } = await reader.read();
|
|
859
|
+
if (done) break;
|
|
860
|
+
chunks.push(decoder.decode(value, { stream: true }));
|
|
861
|
+
}
|
|
862
|
+
return {
|
|
863
|
+
content: [{ type: "text" as const, text: chunks.join("") }],
|
|
864
|
+
};
|
|
865
|
+
},
|
|
866
|
+
);`;
|
|
867
|
+
}
|
|
868
|
+
function isAsyncAuth(authMode) {
|
|
869
|
+
return authMode === "oauth2" || authMode === "oauth2-auth-code";
|
|
870
|
+
}
|
|
871
|
+
function interpolatePath(path) {
|
|
872
|
+
return path.replace(
|
|
873
|
+
/\{([^}]+)\}/g,
|
|
874
|
+
(_m, name) => `\${encodeURIComponent(String(p[${JSON.stringify(name)}] ?? ""))}`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
function renderRequestHelpers(opts) {
|
|
878
|
+
const needsBody = opts.tools.some((t) => t.meta.bodyMode !== "none");
|
|
879
|
+
const needsQuery = opts.tools.some((t) => t.meta.queryParams.length > 0 && !t.meta.pagination);
|
|
880
|
+
const parts = [];
|
|
881
|
+
if (needsQuery) {
|
|
882
|
+
parts.push(`function buildQuery(params: Record<string, unknown>, keys: string[]): string {
|
|
883
|
+
const entries: Array<[string, string]> = [];
|
|
884
|
+
for (const k of keys) {
|
|
885
|
+
const v = params[k];
|
|
886
|
+
if (v === undefined || v === null) continue;
|
|
887
|
+
entries.push([k, String(v)]);
|
|
888
|
+
}
|
|
889
|
+
return entries.length ? "?" + new URLSearchParams(entries).toString() : "";
|
|
890
|
+
}`);
|
|
891
|
+
}
|
|
892
|
+
if (needsBody) {
|
|
893
|
+
parts.push(`function pickBody(params: Record<string, unknown>, pairs: string[][]): unknown {
|
|
894
|
+
if (pairs.length === 1 && pairs[0][1] === "*") return params[pairs[0][0]];
|
|
895
|
+
const out: Record<string, unknown> = {};
|
|
896
|
+
for (const [propKey, fieldKey] of pairs) {
|
|
897
|
+
if (params[propKey] !== undefined) out[fieldKey] = params[propKey];
|
|
898
|
+
}
|
|
899
|
+
return out;
|
|
900
|
+
}`);
|
|
901
|
+
}
|
|
902
|
+
const needsForm = opts.tools.some(
|
|
903
|
+
(t) => t.meta.bodyMode !== "none" && t.meta.bodyContentType === "form" && t.meta.method !== "GET"
|
|
904
|
+
);
|
|
905
|
+
if (needsForm) {
|
|
906
|
+
parts.push(`function formEncode(value: unknown, prefix = "", out = new URLSearchParams()): URLSearchParams {
|
|
907
|
+
if (value === null || value === undefined) return out;
|
|
908
|
+
if (typeof value === "object") {
|
|
909
|
+
if (Array.isArray(value)) {
|
|
910
|
+
value.forEach((v, i) => formEncode(v, prefix ? \`\${prefix}[\${i}]\` : String(i), out));
|
|
911
|
+
} else {
|
|
912
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
913
|
+
formEncode(v, prefix ? \`\${prefix}[\${k}]\` : k, out);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
} else if (prefix) {
|
|
917
|
+
out.append(prefix, String(value));
|
|
918
|
+
}
|
|
919
|
+
return out;
|
|
920
|
+
}`);
|
|
921
|
+
}
|
|
922
|
+
return parts.join("\n\n");
|
|
923
|
+
}
|
|
924
|
+
var MAX_ZOD_DEPTH = 4;
|
|
925
|
+
var MAX_DESCRIBE_LENGTH = 300;
|
|
926
|
+
function jsonSchemaToZod(schema) {
|
|
927
|
+
const entries = Object.entries(schema.properties ?? {});
|
|
928
|
+
if (entries.length === 0) return "{}";
|
|
929
|
+
const fields = entries.map(([name, prop]) => {
|
|
930
|
+
let zodType = schemaToZodType(prop, 0);
|
|
931
|
+
if (!schema.required?.includes(name)) {
|
|
932
|
+
zodType += ".optional()";
|
|
933
|
+
}
|
|
934
|
+
if (prop.description) {
|
|
935
|
+
zodType += `.describe(${JSON.stringify(truncate(prop.description, MAX_DESCRIBE_LENGTH))})`;
|
|
936
|
+
}
|
|
937
|
+
return ` ${JSON.stringify(name)}: ${zodType},`;
|
|
938
|
+
});
|
|
939
|
+
return `{
|
|
940
|
+
${fields.join("\n")}
|
|
941
|
+
}`;
|
|
942
|
+
}
|
|
943
|
+
function truncate(s, max) {
|
|
944
|
+
return s.length > max ? `${s.slice(0, max - 3)}...` : s;
|
|
945
|
+
}
|
|
946
|
+
function schemaToZodType(schema, depth) {
|
|
947
|
+
if (depth > MAX_ZOD_DEPTH) return "z.unknown()";
|
|
948
|
+
let zod = baseZodType(schema, depth);
|
|
949
|
+
const isNullable = schema.nullable === true || Array.isArray(schema.type) && schema.type.includes("null");
|
|
950
|
+
if (isNullable && zod !== "z.unknown()" && zod !== "z.null()") {
|
|
951
|
+
zod += ".nullable()";
|
|
952
|
+
}
|
|
953
|
+
return zod;
|
|
954
|
+
}
|
|
955
|
+
function baseZodType(schema, depth) {
|
|
956
|
+
if (schema.enum && schema.enum.length > 0) {
|
|
957
|
+
const nonNull = schema.enum.filter((v) => v !== null);
|
|
958
|
+
if (nonNull.length === 0) return "z.null()";
|
|
959
|
+
if (nonNull.every((v) => typeof v === "string")) {
|
|
960
|
+
return `z.enum([${nonNull.map((v) => JSON.stringify(v)).join(", ")}])`;
|
|
961
|
+
}
|
|
962
|
+
const literals = nonNull.map((v) => `z.literal(${JSON.stringify(v)})`);
|
|
963
|
+
return literals.length === 1 ? literals[0] : `z.union([${literals.join(", ")}])`;
|
|
964
|
+
}
|
|
965
|
+
const variants = schema.oneOf ?? schema.anyOf;
|
|
966
|
+
if (variants && variants.length > 0) {
|
|
967
|
+
const subs = [
|
|
968
|
+
...new Set(
|
|
969
|
+
variants.filter((v) => v.type !== "null").slice(0, 5).map((v) => schemaToZodType(v, depth + 1))
|
|
970
|
+
)
|
|
971
|
+
];
|
|
972
|
+
if (subs.length === 0) return "z.unknown()";
|
|
973
|
+
if (subs.length === 1) return subs[0];
|
|
974
|
+
return `z.union([${subs.join(", ")}])`;
|
|
975
|
+
}
|
|
976
|
+
if (schema.allOf && schema.allOf.length > 0) {
|
|
977
|
+
const merged = { type: "object", properties: {}, required: [] };
|
|
978
|
+
for (const part of schema.allOf) {
|
|
979
|
+
if (part.properties) Object.assign(merged.properties, part.properties);
|
|
980
|
+
if (part.required) merged.required.push(...part.required);
|
|
981
|
+
}
|
|
982
|
+
if (Object.keys(merged.properties).length > 0) {
|
|
983
|
+
return renderZodObject(merged, depth);
|
|
984
|
+
}
|
|
985
|
+
return "z.unknown()";
|
|
986
|
+
}
|
|
987
|
+
const type = Array.isArray(schema.type) ? schema.type.find((t) => t !== "null") : schema.type;
|
|
988
|
+
switch (type) {
|
|
989
|
+
case "integer":
|
|
990
|
+
return "z.number().int()";
|
|
991
|
+
case "number":
|
|
992
|
+
return "z.number()";
|
|
993
|
+
case "boolean":
|
|
994
|
+
return "z.boolean()";
|
|
995
|
+
case "array":
|
|
996
|
+
return `z.array(${schema.items ? schemaToZodType(schema.items, depth + 1) : "z.unknown()"})`;
|
|
997
|
+
case "object":
|
|
998
|
+
return renderZodObject(schema, depth);
|
|
999
|
+
case "string":
|
|
1000
|
+
return "z.string()";
|
|
1001
|
+
default:
|
|
1002
|
+
if (schema.properties) return renderZodObject(schema, depth);
|
|
1003
|
+
return "z.string()";
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
function renderZodObject(schema, depth) {
|
|
1007
|
+
const entries = Object.entries(schema.properties ?? {});
|
|
1008
|
+
if (entries.length === 0 || depth >= MAX_ZOD_DEPTH) {
|
|
1009
|
+
return "z.record(z.unknown())";
|
|
1010
|
+
}
|
|
1011
|
+
const fields = entries.map(([key, val]) => {
|
|
1012
|
+
let zod = schemaToZodType(val, depth + 1);
|
|
1013
|
+
if (!schema.required?.includes(key)) zod += ".optional()";
|
|
1014
|
+
return `${JSON.stringify(key)}: ${zod}`;
|
|
1015
|
+
});
|
|
1016
|
+
return `z.object({ ${fields.join(", ")} }).passthrough()`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// src/generator/index.ts
|
|
1020
|
+
async function generate(opts) {
|
|
1021
|
+
mkdirSync(opts.outputDir, { recursive: true });
|
|
1022
|
+
mkdirSync(join(opts.outputDir, "src"), { recursive: true });
|
|
1023
|
+
writeFileSync(join(opts.outputDir, "src", "server.ts"), renderServerCode(opts));
|
|
1024
|
+
writeFileSync(
|
|
1025
|
+
join(opts.outputDir, "package.json"),
|
|
1026
|
+
JSON.stringify(
|
|
1027
|
+
{
|
|
1028
|
+
name: slugify(opts.serverName),
|
|
1029
|
+
version: "0.1.0",
|
|
1030
|
+
type: "module",
|
|
1031
|
+
scripts: {
|
|
1032
|
+
build: "tsc",
|
|
1033
|
+
start: "node dist/server.js"
|
|
1034
|
+
},
|
|
1035
|
+
dependencies: {
|
|
1036
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
1037
|
+
zod: "^3.23.0"
|
|
1038
|
+
},
|
|
1039
|
+
devDependencies: {
|
|
1040
|
+
typescript: "^5.7.0",
|
|
1041
|
+
"@types/node": "^22.0.0"
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
null,
|
|
1045
|
+
2
|
|
1046
|
+
)
|
|
1047
|
+
);
|
|
1048
|
+
writeFileSync(
|
|
1049
|
+
join(opts.outputDir, "tsconfig.json"),
|
|
1050
|
+
JSON.stringify(
|
|
1051
|
+
{
|
|
1052
|
+
compilerOptions: {
|
|
1053
|
+
target: "ES2022",
|
|
1054
|
+
module: "Node16",
|
|
1055
|
+
moduleResolution: "Node16",
|
|
1056
|
+
outDir: "dist",
|
|
1057
|
+
rootDir: "src",
|
|
1058
|
+
strict: true,
|
|
1059
|
+
esModuleInterop: true,
|
|
1060
|
+
skipLibCheck: true,
|
|
1061
|
+
declaration: true
|
|
1062
|
+
},
|
|
1063
|
+
include: ["src/**/*"]
|
|
1064
|
+
},
|
|
1065
|
+
null,
|
|
1066
|
+
2
|
|
1067
|
+
)
|
|
1068
|
+
);
|
|
1069
|
+
writeFileSync(
|
|
1070
|
+
join(opts.outputDir, ".env.example"),
|
|
1071
|
+
generateEnvExample(opts)
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
function slugify(name) {
|
|
1075
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
1076
|
+
}
|
|
1077
|
+
function generateEnvExample(opts) {
|
|
1078
|
+
const lines = ["# Generated by openapi-to-mcp", ""];
|
|
1079
|
+
const server = opts.spec.servers?.[0];
|
|
1080
|
+
lines.push(`API_BASE_URL=${server?.url ?? "https://api.example.com"}`);
|
|
1081
|
+
if (opts.auth === "api-key") {
|
|
1082
|
+
lines.push("API_KEY=your-api-key-here");
|
|
1083
|
+
} else if (opts.auth === "bearer") {
|
|
1084
|
+
lines.push("API_TOKEN=your-bearer-token-here");
|
|
1085
|
+
} else if (opts.auth === "oauth2") {
|
|
1086
|
+
lines.push("OAUTH_CLIENT_ID=");
|
|
1087
|
+
lines.push("OAUTH_CLIENT_SECRET=");
|
|
1088
|
+
lines.push("OAUTH_TOKEN_URL=");
|
|
1089
|
+
} else if (opts.auth === "oauth2-auth-code") {
|
|
1090
|
+
lines.push("OAUTH_CLIENT_ID=");
|
|
1091
|
+
lines.push("OAUTH_CLIENT_SECRET=");
|
|
1092
|
+
lines.push("OAUTH_AUTH_URL=");
|
|
1093
|
+
lines.push("OAUTH_TOKEN_URL=");
|
|
1094
|
+
lines.push("OAUTH_SCOPES=");
|
|
1095
|
+
lines.push("OAUTH_REDIRECT_PORT=8976");
|
|
1096
|
+
}
|
|
1097
|
+
return lines.join("\n") + "\n";
|
|
1098
|
+
}
|
|
1099
|
+
export {
|
|
1100
|
+
generate,
|
|
1101
|
+
mapToMcpTools,
|
|
1102
|
+
parseSpec
|
|
1103
|
+
};
|
|
1104
|
+
//# sourceMappingURL=index.js.map
|