api2ai 1.0.28 → 1.0.29
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/README.md +82 -76
- package/package.json +237 -25
- package/src/cli.js +0 -103
- package/src/generate-mcp-use-server.js +0 -18
- package/src/generate.js +0 -90
- package/src/index.js +0 -15
- package/src/spec/classify-risk.js +0 -53
- package/src/spec/extract-tools.js +0 -110
- package/src/spec/load-spec.js +0 -55
- package/src/spec/schema-to-zod.js +0 -213
- package/src/templates/env.js +0 -46
- package/src/templates/http-client.js +0 -126
- package/src/templates/package-json.js +0 -21
- package/src/templates/policy.js +0 -69
- package/src/templates/readme.js +0 -124
- package/src/templates/server-index.js +0 -128
- package/src/templates/tools-config.js +0 -25
- package/src/versions.js +0 -16
package/src/cli.js
DELETED
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* api2ai CLI — generate an mcp-use MCP server from an OpenAPI spec.
|
|
5
|
-
*
|
|
6
|
-
* npx api2ai <openapi-spec> [output-folder] [options]
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { realpathSync } from 'fs';
|
|
10
|
-
import { fileURLToPath } from 'url';
|
|
11
|
-
import { generateMcpServer } from './generate.js';
|
|
12
|
-
|
|
13
|
-
const HELP = `
|
|
14
|
-
OpenAPI to MCP Server Generator (mcp-use framework)
|
|
15
|
-
|
|
16
|
-
Usage:
|
|
17
|
-
npx api2ai <openapi-spec> [output-folder] [options]
|
|
18
|
-
|
|
19
|
-
Arguments:
|
|
20
|
-
openapi-spec Path or URL of an OpenAPI 3.x spec (JSON or YAML)
|
|
21
|
-
output-folder Directory to create the server in (default: ./mcp-server)
|
|
22
|
-
|
|
23
|
-
Options:
|
|
24
|
-
--name <name> Server name (default: api-mcp-server)
|
|
25
|
-
--base-url <url> Override API base URL from the spec
|
|
26
|
-
--port <port> Server port (default: 3000)
|
|
27
|
-
--allow-mutations Enable medium-risk POST/PUT/PATCH/DELETE tools by default
|
|
28
|
-
--include-tags <tags> Only include tools with these tags (comma-separated)
|
|
29
|
-
--exclude-tags <tags> Exclude tools with these tags (comma-separated)
|
|
30
|
-
--exclude-ops <ids> Exclude these operationIds (comma-separated)
|
|
31
|
-
--approve-writes Write REQUIRE_APPROVALS=false into the generated .env
|
|
32
|
-
--help, -h Show this help message
|
|
33
|
-
|
|
34
|
-
Examples:
|
|
35
|
-
npx api2ai ./petstore.json ./my-server
|
|
36
|
-
npx api2ai https://petstore3.swagger.io/api/v3/openapi.json ./petstore-mcp \\
|
|
37
|
-
--name petstore-api --port 8080
|
|
38
|
-
`;
|
|
39
|
-
|
|
40
|
-
const list = (value) => value.split(',').map(s => s.trim()).filter(Boolean);
|
|
41
|
-
|
|
42
|
-
/** Parse argv (without node + script) into generateMcpServer options. */
|
|
43
|
-
export function parseArgs(args) {
|
|
44
|
-
const options = {
|
|
45
|
-
specPath: args[0],
|
|
46
|
-
outputFolder: './mcp-server',
|
|
47
|
-
baseUrl: undefined,
|
|
48
|
-
serverName: 'api-mcp-server',
|
|
49
|
-
port: 3000,
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const takesValue = new Set(['--base-url', '--name', '--port', '--include-tags', '--exclude-tags', '--exclude-ops']);
|
|
53
|
-
|
|
54
|
-
for (let i = 1; i < args.length; i++) {
|
|
55
|
-
const arg = args[i];
|
|
56
|
-
if (takesValue.has(arg)) {
|
|
57
|
-
const value = args[++i];
|
|
58
|
-
if (value === undefined) throw new Error(`${arg} needs a value`);
|
|
59
|
-
if (arg === '--base-url') options.baseUrl = value;
|
|
60
|
-
else if (arg === '--name') options.serverName = value;
|
|
61
|
-
else if (arg === '--port') {
|
|
62
|
-
options.port = Number.parseInt(value, 10);
|
|
63
|
-
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65535) {
|
|
64
|
-
throw new Error(`Invalid --port: ${value}`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
else if (arg === '--include-tags') options.includeTags = list(value);
|
|
68
|
-
else if (arg === '--exclude-tags') options.excludeTags = list(value);
|
|
69
|
-
else if (arg === '--exclude-ops') options.excludeOperationIds = list(value);
|
|
70
|
-
} else if (arg === '--allow-mutations') {
|
|
71
|
-
options.allowMutations = true;
|
|
72
|
-
} else if (arg === '--approve-writes') {
|
|
73
|
-
options.requireApprovals = false;
|
|
74
|
-
} else if (arg.startsWith('-')) {
|
|
75
|
-
throw new Error(`Unknown option: ${arg} (see --help)`);
|
|
76
|
-
} else {
|
|
77
|
-
options.outputFolder = arg;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return options;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export async function main(args = process.argv.slice(2)) {
|
|
85
|
-
if (args.length < 1 || args.includes('--help') || args.includes('-h')) {
|
|
86
|
-
console.log(HELP);
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
const options = parseArgs(args);
|
|
90
|
-
await generateMcpServer(options.specPath, options.outputFolder, options);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// realpathSync follows the npm/npx bin symlink so this still matches when
|
|
94
|
-
// invoked as `npx api2ai` (argv[1] is the symlink in node_modules/.bin).
|
|
95
|
-
export const isMainModule = (metaUrl) =>
|
|
96
|
-
Boolean(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(metaUrl);
|
|
97
|
-
|
|
98
|
-
if (isMainModule(import.meta.url)) {
|
|
99
|
-
main().catch(e => {
|
|
100
|
-
console.error('❌ Error:', e.message);
|
|
101
|
-
process.exit(1);
|
|
102
|
-
});
|
|
103
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Former single-file entry point, kept so `node src/generate-mcp-use-server.js`
|
|
5
|
-
* and imports of this path keep working. The code now lives in ./index.js
|
|
6
|
-
* (library) and ./cli.js (command).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { main, isMainModule } from './cli.js';
|
|
10
|
-
|
|
11
|
-
export * from './index.js';
|
|
12
|
-
|
|
13
|
-
if (isMainModule(import.meta.url)) {
|
|
14
|
-
main().catch(e => {
|
|
15
|
-
console.error('❌ Error:', e.message);
|
|
16
|
-
process.exit(1);
|
|
17
|
-
});
|
|
18
|
-
}
|
package/src/generate.js
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { loadOpenApiSpec } from './spec/load-spec.js';
|
|
4
|
-
import { extractTools } from './spec/extract-tools.js';
|
|
5
|
-
import { generatePackageJson } from './templates/package-json.js';
|
|
6
|
-
import { generateEnvFile } from './templates/env.js';
|
|
7
|
-
import { generatePolicy } from './templates/policy.js';
|
|
8
|
-
import { generateHttpClient } from './templates/http-client.js';
|
|
9
|
-
import { generateToolsConfig } from './templates/tools-config.js';
|
|
10
|
-
import { generateServerIndex } from './templates/server-index.js';
|
|
11
|
-
import { generateReadme } from './templates/readme.js';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Render every file of the server without touching the disk.
|
|
15
|
-
* @returns {{ files: Array<{ path: string, content: string }>, tools: object[], baseUrl?: string }}
|
|
16
|
-
*/
|
|
17
|
-
export function renderMcpServer(spec, options = {}) {
|
|
18
|
-
const {
|
|
19
|
-
serverName = 'openapi-mcp-server',
|
|
20
|
-
port = 3000,
|
|
21
|
-
specPath = '(in-memory spec)',
|
|
22
|
-
requireApprovals = true,
|
|
23
|
-
} = options;
|
|
24
|
-
|
|
25
|
-
const tools = extractTools(spec, { ...options, specUrl: options.specUrl ?? specPath });
|
|
26
|
-
const baseUrl = options.baseUrl || tools[0]?.baseUrl;
|
|
27
|
-
|
|
28
|
-
const files = [
|
|
29
|
-
{ path: 'package.json', content: generatePackageJson(serverName, tools) },
|
|
30
|
-
{ path: '.env', content: generateEnvFile({ baseUrl, port, requireApprovals }) },
|
|
31
|
-
{ path: '.env.example', content: generateEnvFile({ baseUrl, port, requireApprovals, example: true }) },
|
|
32
|
-
{ path: 'src/policy.js', content: generatePolicy(baseUrl) },
|
|
33
|
-
{ path: 'src/http-client.js', content: generateHttpClient() },
|
|
34
|
-
{ path: 'src/tools-config.js', content: generateToolsConfig(tools) },
|
|
35
|
-
{ path: 'src/index.js', content: generateServerIndex(serverName, tools, baseUrl, port) },
|
|
36
|
-
{ path: 'README.md', content: generateReadme(serverName, tools, specPath, baseUrl, port) },
|
|
37
|
-
{ path: '.gitignore', content: 'node_modules/\n.env\n*.log\n.mcp-use/\n' },
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
return { files, tools, baseUrl };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Load a spec, generate an mcp-use server from it and write it to `outputFolder`.
|
|
45
|
-
*
|
|
46
|
-
* @param {string} specPathOrUrl local file or http(s) URL (JSON or YAML)
|
|
47
|
-
* @param {string} outputFolder
|
|
48
|
-
* @param {object} [options] see extractTools; plus serverName, port, requireApprovals, quiet
|
|
49
|
-
*/
|
|
50
|
-
export async function generateMcpServer(specPathOrUrl, outputFolder, options = {}) {
|
|
51
|
-
const { port = 3000, quiet = false } = options;
|
|
52
|
-
const log = quiet ? () => {} : console.log;
|
|
53
|
-
|
|
54
|
-
log(`\n📖 Loading OpenAPI spec: ${specPathOrUrl}`);
|
|
55
|
-
const spec = await loadOpenApiSpec(specPathOrUrl);
|
|
56
|
-
log(` Title: ${spec.info?.title || 'Unknown'}`);
|
|
57
|
-
log(` Version: ${spec.info?.version || 'Unknown'}`);
|
|
58
|
-
|
|
59
|
-
const { files, tools } = renderMcpServer(spec, { ...options, specPath: specPathOrUrl });
|
|
60
|
-
log(`✅ Extracted ${tools.length} tools\n`);
|
|
61
|
-
|
|
62
|
-
for (const file of files) {
|
|
63
|
-
const target = path.join(outputFolder, file.path);
|
|
64
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
65
|
-
// An existing .env holds the user's credentials; regenerating must not wipe them.
|
|
66
|
-
if (file.path === '.env' && await fs.access(target).then(() => true, () => false)) {
|
|
67
|
-
log(` • .env (kept existing)`);
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
await fs.writeFile(target, file.content);
|
|
71
|
-
log(` ✓ ${file.path}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
log(`
|
|
75
|
-
🎉 MCP server generated
|
|
76
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
77
|
-
|
|
78
|
-
cd ${outputFolder}
|
|
79
|
-
npm install
|
|
80
|
-
npm start # http://localhost:${port}/mcp
|
|
81
|
-
npm run dev # hot reload + Inspector at /mcp/inspector
|
|
82
|
-
`);
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
outputFolder,
|
|
86
|
-
toolCount: tools.length,
|
|
87
|
-
tools: tools.map(t => t.name),
|
|
88
|
-
port,
|
|
89
|
-
};
|
|
90
|
-
}
|
package/src/index.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* api2ai — generate an mcp-use MCP server from any OpenAPI specification.
|
|
3
|
-
*
|
|
4
|
-
* spec/ load a spec, convert schemas to Zod, classify risk, extract tools
|
|
5
|
-
* templates/ one module per file of the generated server
|
|
6
|
-
* generate.js render + write the server
|
|
7
|
-
* cli.js the `api2ai` command
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export { generateMcpServer, renderMcpServer } from './generate.js';
|
|
11
|
-
export { loadOpenApiSpec } from './spec/load-spec.js';
|
|
12
|
-
export { extractTools, resolveServerUrl } from './spec/extract-tools.js';
|
|
13
|
-
export { classifyRisk, toolAnnotations } from './spec/classify-risk.js';
|
|
14
|
-
export { schemaToZod, buildZodSchema, sanitizePropertyName } from './spec/schema-to-zod.js';
|
|
15
|
-
export { DEPENDENCY_VERSIONS, NODE_ENGINE } from './versions.js';
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
const READ_ONLY_METHODS = ['get', 'head', 'options'];
|
|
2
|
-
|
|
3
|
-
// Matched against operationId, summary, description, path and tags. Broad on
|
|
4
|
-
// purpose: a false "high" only costs an env flag, a false "low" exposes a
|
|
5
|
-
// destructive endpoint to an agent with no approval step.
|
|
6
|
-
const DANGEROUS_PATTERNS = [
|
|
7
|
-
'delete', 'remove', 'destroy', 'purge',
|
|
8
|
-
'payment', 'payout', 'billing', 'invoice',
|
|
9
|
-
'admin', 'role', 'permission', 'token', 'secret', 'key',
|
|
10
|
-
'webhook', 'auth', 'oauth', 'user', 'member',
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Classify an operation as low / medium / high risk.
|
|
15
|
-
*
|
|
16
|
-
* low — read-only method and no dangerous keyword: enabled, no approval
|
|
17
|
-
* medium — mutating method: disabled unless ALLOW_RESTRICTED_TOOLS=true
|
|
18
|
-
* high — any dangerous keyword, whatever the method
|
|
19
|
-
*/
|
|
20
|
-
export function classifyRisk(operation, method, pathTemplate) {
|
|
21
|
-
const tags = operation.tags || [];
|
|
22
|
-
const haystack = [operation.operationId, operation.summary, operation.description, pathTemplate, ...tags]
|
|
23
|
-
.filter(Boolean)
|
|
24
|
-
.join(' ')
|
|
25
|
-
.toLowerCase();
|
|
26
|
-
|
|
27
|
-
const isMutation = !READ_ONLY_METHODS.includes(method);
|
|
28
|
-
const matchedDanger = DANGEROUS_PATTERNS.some(p => haystack.includes(p));
|
|
29
|
-
const riskLevel = matchedDanger ? 'high' : isMutation ? 'medium' : 'low';
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
tags,
|
|
33
|
-
isMutation,
|
|
34
|
-
riskLevel,
|
|
35
|
-
requiresApproval: riskLevel !== 'low',
|
|
36
|
-
allowedInInspector: riskLevel === 'low',
|
|
37
|
-
enabledByDefault: riskLevel === 'low',
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* MCP tool annotations (hints a client may show or act on). They describe
|
|
43
|
-
* the HTTP semantics, independent of the risk policy above.
|
|
44
|
-
*/
|
|
45
|
-
export function toolAnnotations(method, title) {
|
|
46
|
-
return {
|
|
47
|
-
title,
|
|
48
|
-
readOnlyHint: READ_ONLY_METHODS.includes(method),
|
|
49
|
-
destructiveHint: method === 'delete',
|
|
50
|
-
idempotentHint: ['get', 'head', 'options', 'put', 'delete'].includes(method),
|
|
51
|
-
openWorldHint: true,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
import { classifyRisk, toolAnnotations } from './classify-risk.js';
|
|
2
|
-
import { buildZodSchema, deref, mergeParameters } from './schema-to-zod.js';
|
|
3
|
-
|
|
4
|
-
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The API base URL from `servers[0]`, with `{variables}` replaced by their
|
|
8
|
-
* defaults and a relative URL ("/api/v3") resolved against the spec's own URL.
|
|
9
|
-
*/
|
|
10
|
-
export function resolveServerUrl(spec, specUrl) {
|
|
11
|
-
const server = spec.servers?.[0];
|
|
12
|
-
if (!server?.url) return undefined;
|
|
13
|
-
let url = server.url.replace(/\{([^}]+)\}/g, (match, name) => server.variables?.[name]?.default ?? match);
|
|
14
|
-
if (!/^https?:\/\//i.test(url)) {
|
|
15
|
-
if (!specUrl || !/^https?:\/\//i.test(specUrl)) return undefined;
|
|
16
|
-
url = new URL(url, specUrl).toString();
|
|
17
|
-
}
|
|
18
|
-
return url.replace(/\/+$/, '');
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const toToolName = (operationId) =>
|
|
22
|
-
operationId.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 64) || 'tool';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Turn every operation in the spec into a tool description.
|
|
26
|
-
*
|
|
27
|
-
* @param {object} spec parsed OpenAPI document
|
|
28
|
-
* @param {object} [options]
|
|
29
|
-
* @param {string} [options.baseUrl] override `servers[0].url`
|
|
30
|
-
* @param {string} [options.specUrl] where the spec came from, for relative server URLs
|
|
31
|
-
* @param {string[]} [options.excludeOperationIds]
|
|
32
|
-
* @param {string[]} [options.includeTags] keep only operations carrying one of these tags
|
|
33
|
-
* @param {string[]} [options.excludeTags] drop operations carrying any of these tags
|
|
34
|
-
* @param {boolean} [options.allowMutations] enable medium-risk tools by default
|
|
35
|
-
* @param {(tool: object) => boolean} [options.filterFn]
|
|
36
|
-
*/
|
|
37
|
-
export function extractTools(spec, options = {}) {
|
|
38
|
-
const {
|
|
39
|
-
excludeOperationIds = [],
|
|
40
|
-
filterFn,
|
|
41
|
-
allowMutations = false,
|
|
42
|
-
includeTags = [],
|
|
43
|
-
excludeTags = [],
|
|
44
|
-
} = options;
|
|
45
|
-
const baseUrl = options.baseUrl || resolveServerUrl(spec, options.specUrl);
|
|
46
|
-
|
|
47
|
-
const tools = [];
|
|
48
|
-
const usedNames = new Set();
|
|
49
|
-
|
|
50
|
-
for (const [pathTemplate, pathItem] of Object.entries(spec.paths || {})) {
|
|
51
|
-
for (const method of HTTP_METHODS) {
|
|
52
|
-
const operation = pathItem[method];
|
|
53
|
-
if (!operation) continue;
|
|
54
|
-
|
|
55
|
-
const operationId = operation.operationId || `${method}_${pathTemplate.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
56
|
-
if (excludeOperationIds.includes(operationId)) continue;
|
|
57
|
-
|
|
58
|
-
const risk = classifyRisk(operation, method, pathTemplate);
|
|
59
|
-
if (allowMutations && risk.isMutation && risk.riskLevel === 'medium') {
|
|
60
|
-
risk.enabledByDefault = true;
|
|
61
|
-
risk.requiresApproval = false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (includeTags.length > 0 && !risk.tags.some(t => includeTags.includes(t))) continue;
|
|
65
|
-
if (excludeTags.length > 0 && risk.tags.some(t => excludeTags.includes(t))) continue;
|
|
66
|
-
|
|
67
|
-
// Two operationIds can sanitize to the same name; MCP requires unique names.
|
|
68
|
-
let name = toToolName(operationId);
|
|
69
|
-
for (let n = 2; usedNames.has(name); n++) name = `${toToolName(operationId).slice(0, 60)}_${n}`;
|
|
70
|
-
|
|
71
|
-
const description = (operation.summary && operation.description && operation.description !== operation.summary
|
|
72
|
-
? `${operation.summary}\n\n${operation.description}`
|
|
73
|
-
: operation.summary || operation.description || `${method.toUpperCase()} ${pathTemplate}`
|
|
74
|
-
).substring(0, 1024);
|
|
75
|
-
|
|
76
|
-
const parameters = mergeParameters(pathItem.parameters, operation.parameters, spec)
|
|
77
|
-
.filter(p => p.in !== 'cookie');
|
|
78
|
-
const requestBody = deref(operation.requestBody, spec);
|
|
79
|
-
|
|
80
|
-
const tool = {
|
|
81
|
-
name,
|
|
82
|
-
title: operation.summary || name,
|
|
83
|
-
description,
|
|
84
|
-
zodSchema: buildZodSchema(operation, pathItem.parameters, spec),
|
|
85
|
-
annotations: toolAnnotations(method, operation.summary || name),
|
|
86
|
-
method,
|
|
87
|
-
pathTemplate,
|
|
88
|
-
executionParameters: parameters.map(p => ({ name: p.name, in: p.in })),
|
|
89
|
-
// Must match the media type buildZodSchema took the body schema from.
|
|
90
|
-
requestBodyContentType: requestBody?.content
|
|
91
|
-
? ('application/json' in requestBody.content ? 'application/json' : Object.keys(requestBody.content)[0])
|
|
92
|
-
: undefined,
|
|
93
|
-
operationId,
|
|
94
|
-
baseUrl,
|
|
95
|
-
tags: risk.tags,
|
|
96
|
-
isMutation: risk.isMutation,
|
|
97
|
-
riskLevel: risk.riskLevel,
|
|
98
|
-
requiresApproval: risk.requiresApproval,
|
|
99
|
-
allowedInInspector: risk.allowedInInspector,
|
|
100
|
-
enabledByDefault: risk.enabledByDefault,
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
if (filterFn && !filterFn(tool)) continue;
|
|
104
|
-
usedNames.add(name);
|
|
105
|
-
tools.push(tool);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return tools;
|
|
110
|
-
}
|
package/src/spec/load-spec.js
DELETED
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
|
-
|
|
3
|
-
const isUrl = (s) => s.startsWith('http://') || s.startsWith('https://');
|
|
4
|
-
const looksLikeYaml = (name) => /\.ya?ml($|\?)/i.test(name);
|
|
5
|
-
|
|
6
|
-
async function parseYaml(content) {
|
|
7
|
-
const yaml = await import('js-yaml').catch(() => null);
|
|
8
|
-
if (!yaml) {
|
|
9
|
-
throw new Error('YAML spec detected but js-yaml is not installed. Run: npm install js-yaml');
|
|
10
|
-
}
|
|
11
|
-
return (yaml.default || yaml).load(content);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Parse spec text as JSON, falling back to YAML when the source says it is
|
|
16
|
-
* YAML (extension or content-type) or when the JSON parse fails — remote
|
|
17
|
-
* specs are often served as text/plain whatever their format.
|
|
18
|
-
*/
|
|
19
|
-
async function parseSpecText(content, { source, contentType = '' }) {
|
|
20
|
-
if (looksLikeYaml(source) || /yaml/i.test(contentType)) {
|
|
21
|
-
return parseYaml(content);
|
|
22
|
-
}
|
|
23
|
-
try {
|
|
24
|
-
return JSON.parse(content);
|
|
25
|
-
} catch (jsonError) {
|
|
26
|
-
try {
|
|
27
|
-
return await parseYaml(content);
|
|
28
|
-
} catch {
|
|
29
|
-
throw new Error(`Could not parse spec ${source} as JSON: ${jsonError.message}`);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Load an OpenAPI document from a local path or an http(s) URL. */
|
|
35
|
-
export async function loadOpenApiSpec(specPathOrUrl) {
|
|
36
|
-
let spec;
|
|
37
|
-
if (isUrl(specPathOrUrl)) {
|
|
38
|
-
const response = await fetch(specPathOrUrl, { headers: { Accept: 'application/json, application/yaml, text/yaml, */*' } });
|
|
39
|
-
if (!response.ok) {
|
|
40
|
-
throw new Error(`Failed to fetch spec: ${response.status} ${response.statusText}`);
|
|
41
|
-
}
|
|
42
|
-
spec = await parseSpecText(await response.text(), {
|
|
43
|
-
source: specPathOrUrl,
|
|
44
|
-
contentType: response.headers.get('content-type') || '',
|
|
45
|
-
});
|
|
46
|
-
} else {
|
|
47
|
-
const content = await fs.readFile(specPathOrUrl, 'utf-8');
|
|
48
|
-
spec = await parseSpecText(content, { source: specPathOrUrl });
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
if (!spec || typeof spec !== 'object' || !spec.paths) {
|
|
52
|
-
throw new Error(`${specPathOrUrl} is not an OpenAPI document (no "paths" object)`);
|
|
53
|
-
}
|
|
54
|
-
return spec;
|
|
55
|
-
}
|
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenAPI schema → Zod source code (a string the generated server evaluates).
|
|
3
|
-
*
|
|
4
|
-
* Every user-controlled string that lands in generated code goes through
|
|
5
|
-
* `literal()` (JSON.stringify): a description containing a newline, a
|
|
6
|
-
* backslash or `*\/` used to produce a server that failed to parse.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** A JS string literal safe to splice into generated source. */
|
|
10
|
-
export const literal = (value) => JSON.stringify(String(value));
|
|
11
|
-
|
|
12
|
-
/** A JS object key: bare when it is an identifier, quoted otherwise. */
|
|
13
|
-
export function sanitizePropertyName(name) {
|
|
14
|
-
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : literal(name);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const describe = (text) => (text ? `.describe(${literal(text)})` : '');
|
|
18
|
-
|
|
19
|
-
/** Follow a local `#/components/...` JSON pointer. External refs are not fetched. */
|
|
20
|
-
function resolveRef(ref, spec) {
|
|
21
|
-
if (!ref.startsWith('#/')) return null;
|
|
22
|
-
let node = spec;
|
|
23
|
-
for (const raw of ref.slice(2).split('/')) {
|
|
24
|
-
const key = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
25
|
-
node = node?.[key];
|
|
26
|
-
if (node === undefined) return null;
|
|
27
|
-
}
|
|
28
|
-
return node;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function literalUnion(values) {
|
|
32
|
-
const nonNull = values.filter(v => v !== null);
|
|
33
|
-
const parts = nonNull.map(v => `z.literal(${JSON.stringify(v)})`);
|
|
34
|
-
let zod = parts.length === 0 ? 'z.null()' : parts.length === 1 ? parts[0] : `z.union([${parts.join(', ')}])`;
|
|
35
|
-
if (nonNull.length !== values.length && parts.length > 0) zod += '.nullable()';
|
|
36
|
-
return zod;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function stringZod(schema) {
|
|
40
|
-
switch (schema.format) {
|
|
41
|
-
// offset: true — LLMs routinely send `2024-01-01T00:00:00+02:00`.
|
|
42
|
-
case 'date-time': return 'z.iso.datetime({ offset: true })';
|
|
43
|
-
case 'date': return 'z.iso.date()';
|
|
44
|
-
case 'email': return 'z.email()';
|
|
45
|
-
case 'uri':
|
|
46
|
-
case 'url': return 'z.url()';
|
|
47
|
-
case 'uuid': return 'z.uuid()';
|
|
48
|
-
default: return 'z.string()';
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function objectZod(schema, ctx, depth) {
|
|
53
|
-
const pad = ' '.repeat(depth + 2);
|
|
54
|
-
const closePad = ' '.repeat(depth + 1);
|
|
55
|
-
const props = Object.entries(schema.properties || {});
|
|
56
|
-
|
|
57
|
-
if (props.length === 0) {
|
|
58
|
-
if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
59
|
-
return `z.record(z.string(), ${toZod(schema.additionalProperties, ctx, true, depth + 1)})`;
|
|
60
|
-
}
|
|
61
|
-
return 'z.record(z.string(), z.unknown())';
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const body = props
|
|
65
|
-
.map(([key, val]) => {
|
|
66
|
-
const isReq = (schema.required || []).includes(key);
|
|
67
|
-
const resolved = val?.$ref ? resolveRef(val.$ref, ctx.spec) || val : val;
|
|
68
|
-
return `${pad}${sanitizePropertyName(key)}: ${toZod(val, ctx, isReq, depth + 1)}${describe(resolved?.description)}`;
|
|
69
|
-
})
|
|
70
|
-
.join(',\n');
|
|
71
|
-
|
|
72
|
-
// zod 4's z.object() strips unknown keys; keep them when the spec allows extras.
|
|
73
|
-
const ctor = schema.additionalProperties ? 'z.looseObject' : 'z.object';
|
|
74
|
-
return `${ctor}({\n${body}\n${closePad}})`;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Merge `allOf` members that are plain objects into one object schema. */
|
|
78
|
-
function mergeAllOf(members, ctx) {
|
|
79
|
-
const resolved = members.map(m => (m?.$ref ? resolveRef(m.$ref, ctx.spec) : m));
|
|
80
|
-
if (!resolved.every(m => m && (m.type === 'object' || m.properties) && !m.allOf && !m.oneOf && !m.anyOf)) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
return {
|
|
84
|
-
type: 'object',
|
|
85
|
-
properties: Object.assign({}, ...resolved.map(m => m.properties || {})),
|
|
86
|
-
required: resolved.flatMap(m => m.required || []),
|
|
87
|
-
additionalProperties: resolved.some(m => m.additionalProperties),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function toZod(schema, ctx, required, depth) {
|
|
92
|
-
let zod = baseZod(schema, ctx, depth);
|
|
93
|
-
if (!required) zod += '.optional()';
|
|
94
|
-
return zod;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function baseZod(schema, ctx, depth) {
|
|
98
|
-
if (!schema || typeof schema !== 'object') return 'z.unknown()';
|
|
99
|
-
|
|
100
|
-
if (schema.$ref) {
|
|
101
|
-
// A cycle (Node.children: Node[]) is cut to z.unknown() rather than
|
|
102
|
-
// expanding forever; z.lazy would need named declarations in the output.
|
|
103
|
-
if (ctx.seen.has(schema.$ref)) return 'z.unknown()';
|
|
104
|
-
const target = resolveRef(schema.$ref, ctx.spec);
|
|
105
|
-
if (!target) return 'z.unknown()';
|
|
106
|
-
ctx.seen.add(schema.$ref);
|
|
107
|
-
try {
|
|
108
|
-
return baseZod(target, ctx, depth);
|
|
109
|
-
} finally {
|
|
110
|
-
ctx.seen.delete(schema.$ref);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// OpenAPI 3.1: `type: ['string', 'null']`; 3.0: `nullable: true`.
|
|
115
|
-
let type = schema.type;
|
|
116
|
-
let nullable = schema.nullable === true;
|
|
117
|
-
if (Array.isArray(type)) {
|
|
118
|
-
nullable = nullable || type.includes('null');
|
|
119
|
-
const rest = type.filter(t => t !== 'null');
|
|
120
|
-
if (rest.length !== 1) {
|
|
121
|
-
return rest.length === 0 ? 'z.null()' : `z.union([${rest.map(t => baseZod({ ...schema, type: t }, ctx, depth)).join(', ')}])${nullable ? '.nullable()' : ''}`;
|
|
122
|
-
}
|
|
123
|
-
type = rest[0];
|
|
124
|
-
}
|
|
125
|
-
if (!type && schema.properties) type = 'object';
|
|
126
|
-
if (!type && schema.items) type = 'array';
|
|
127
|
-
|
|
128
|
-
let zod;
|
|
129
|
-
if (schema.const !== undefined) {
|
|
130
|
-
zod = `z.literal(${JSON.stringify(schema.const)})`;
|
|
131
|
-
} else if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
132
|
-
zod = schema.enum.every(v => typeof v === 'string')
|
|
133
|
-
? `z.enum([${schema.enum.map(v => literal(v)).join(', ')}])`
|
|
134
|
-
: literalUnion(schema.enum);
|
|
135
|
-
} else if (schema.allOf) {
|
|
136
|
-
const merged = mergeAllOf(schema.allOf, ctx);
|
|
137
|
-
zod = merged
|
|
138
|
-
? objectZod(merged, ctx, depth)
|
|
139
|
-
: schema.allOf.map(s => baseZod(s, ctx, depth)).reduce((a, b) => `z.intersection(${a}, ${b})`);
|
|
140
|
-
} else if (schema.anyOf || schema.oneOf) {
|
|
141
|
-
const options = (schema.anyOf || schema.oneOf).map(s => baseZod(s, ctx, depth));
|
|
142
|
-
zod = options.length === 1 ? options[0] : `z.union([${options.join(', ')}])`;
|
|
143
|
-
} else {
|
|
144
|
-
switch (type) {
|
|
145
|
-
case 'string': zod = stringZod(schema); break;
|
|
146
|
-
case 'integer': zod = 'z.int()'; break;
|
|
147
|
-
case 'number': zod = 'z.number()'; break;
|
|
148
|
-
case 'boolean': zod = 'z.boolean()'; break;
|
|
149
|
-
case 'null': zod = 'z.null()'; break;
|
|
150
|
-
case 'array': zod = `z.array(${baseZod(schema.items, ctx, depth)})`; break;
|
|
151
|
-
case 'object': zod = objectZod(schema, ctx, depth); break;
|
|
152
|
-
default: zod = 'z.unknown()';
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
return nullable && !zod.endsWith('.nullable()') ? `${zod}.nullable()` : zod;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Zod source for one schema.
|
|
161
|
-
* @param {object} schema OpenAPI schema object (may be a `$ref`)
|
|
162
|
-
* @param {boolean} [required=false] omit `.optional()`
|
|
163
|
-
* @param {object} [spec] the whole document, for resolving local `$ref`s
|
|
164
|
-
*/
|
|
165
|
-
export function schemaToZod(schema, required = false, spec = {}) {
|
|
166
|
-
return toZod(schema, { spec, seen: new Set() }, required, 0);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Resolve a `$ref`'d parameter or request body; returns the input otherwise. */
|
|
170
|
-
export function deref(node, spec) {
|
|
171
|
-
return node?.$ref ? resolveRef(node.$ref, spec) || node : node;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* The `z.object({...})` source for a tool's input: every path/query/header
|
|
176
|
-
* parameter, plus `requestBody` when the operation takes one.
|
|
177
|
-
*/
|
|
178
|
-
export function buildZodSchema(operation, pathParams = [], spec = {}) {
|
|
179
|
-
const ctx = { spec, seen: new Set() };
|
|
180
|
-
const properties = [];
|
|
181
|
-
|
|
182
|
-
for (const param of mergeParameters(pathParams, operation.parameters, spec)) {
|
|
183
|
-
if (param.in === 'cookie') continue; // the HTTP client never sends cookies
|
|
184
|
-
const zodType = toZod(param.schema || { type: 'string' }, ctx, param.in === 'path' || param.required, 1);
|
|
185
|
-
properties.push(` ${sanitizePropertyName(param.name)}: ${zodType}${describe(param.description)}`);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const body = deref(operation.requestBody, spec);
|
|
189
|
-
if (body?.content) {
|
|
190
|
-
const mediaType = body.content['application/json'] || Object.values(body.content)[0];
|
|
191
|
-
if (mediaType?.schema) {
|
|
192
|
-
const zodType = toZod(mediaType.schema, ctx, Boolean(body.required), 1);
|
|
193
|
-
properties.push(` requestBody: ${zodType}${describe(body.description || 'Request body')}`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
return properties.length === 0 ? 'z.object({})' : `z.object({\n${properties.join(',\n')}\n })`;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Path-item parameters merged with operation parameters. Per the OpenAPI
|
|
202
|
-
* spec an operation parameter overrides a path-level one with the same
|
|
203
|
-
* name + location (it used to be emitted twice — a duplicate object key).
|
|
204
|
-
*/
|
|
205
|
-
export function mergeParameters(pathParams = [], operationParams = [], spec = {}) {
|
|
206
|
-
const byKey = new Map();
|
|
207
|
-
for (const raw of [...pathParams, ...operationParams]) {
|
|
208
|
-
const param = deref(raw, spec);
|
|
209
|
-
if (!param?.name) continue;
|
|
210
|
-
byKey.set(`${param.in}:${param.name}`, param);
|
|
211
|
-
}
|
|
212
|
-
return [...byKey.values()];
|
|
213
|
-
}
|