api2ai 1.0.28 → 1.0.30

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/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
- }
@@ -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
- }