api2ai 1.0.26 → 1.0.28
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 +76 -82
- package/package.json +25 -237
- package/src/cli.js +103 -0
- package/src/generate-mcp-use-server.js +18 -0
- package/src/generate.js +90 -0
- package/src/index.js +15 -0
- package/src/spec/classify-risk.js +53 -0
- package/src/spec/extract-tools.js +110 -0
- package/src/spec/load-spec.js +55 -0
- package/src/spec/schema-to-zod.js +213 -0
- package/src/templates/env.js +46 -0
- package/src/templates/http-client.js +126 -0
- package/src/templates/package-json.js +21 -0
- package/src/templates/policy.js +69 -0
- package/src/templates/readme.js +124 -0
- package/src/templates/server-index.js +128 -0
- package/src/templates/tools-config.js +25 -0
- package/src/versions.js +16 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.env` and `.env.example`. They differ only in placeholder credentials, so
|
|
3
|
+
* one template renders both (they used to be two copies that drifted).
|
|
4
|
+
*/
|
|
5
|
+
export function generateEnvFile({ baseUrl, port, requireApprovals = true, example = false }) {
|
|
6
|
+
const apiHost = safeHost(baseUrl);
|
|
7
|
+
return `# Server Configuration
|
|
8
|
+
PORT=${port}
|
|
9
|
+
# 127.0.0.1 locally; set HOST=0.0.0.0 to serve on a network / in a container
|
|
10
|
+
# HOST=0.0.0.0
|
|
11
|
+
NODE_ENV=development
|
|
12
|
+
|
|
13
|
+
# API Configuration
|
|
14
|
+
API_BASE_URL=${baseUrl || 'https://api.example.com'}
|
|
15
|
+
|
|
16
|
+
# Authentication
|
|
17
|
+
${example ? 'API_KEY=your-api-key-here' : '# API_KEY=your-api-key'}
|
|
18
|
+
# API_AUTH_HEADER=Header-Name:header-value
|
|
19
|
+
|
|
20
|
+
# Security — allowed API hosts (comma-separated). Empty = only API_BASE_URL's host${apiHost ? ` (${apiHost})` : ''}.
|
|
21
|
+
# ALLOWED_API_HOSTS=${apiHost || 'api.example.com'}
|
|
22
|
+
|
|
23
|
+
# Tool policy — set to true to allow medium/high-risk (mutating) tools
|
|
24
|
+
ALLOW_RESTRICTED_TOOLS=false
|
|
25
|
+
# Set to false to bypass per-call approval requirement for restricted tools
|
|
26
|
+
REQUIRE_APPROVALS=${requireApprovals ? 'true' : 'false'}
|
|
27
|
+
|
|
28
|
+
# Request limits
|
|
29
|
+
REQUEST_TIMEOUT_MS=30000
|
|
30
|
+
MAX_RESPONSE_BYTES=10485760
|
|
31
|
+
|
|
32
|
+
# Public URL of this server, when it sits behind a proxy
|
|
33
|
+
# MCP_URL=https://your-production-url.com
|
|
34
|
+
|
|
35
|
+
# Browser origins allowed to call /mcp (comma-separated hostnames or URLs)
|
|
36
|
+
# ALLOWED_ORIGINS=app1.com,https://app2.com
|
|
37
|
+
`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function safeHost(url) {
|
|
41
|
+
try {
|
|
42
|
+
return url ? new URL(url).host : '';
|
|
43
|
+
} catch {
|
|
44
|
+
return '';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** `src/http-client.js` of the generated server: one outbound request per tool call. */
|
|
2
|
+
export function generateHttpClient() {
|
|
3
|
+
return `// HTTP client for API requests
|
|
4
|
+
import { sanitizeHeaderParams, MAX_RESPONSE_BYTES, REQUEST_TIMEOUT_MS } from './policy.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Join base URL and path template. Concatenated rather than \`new URL(path, base)\`:
|
|
8
|
+
* an absolute path would drop the base's own path (\`/api/v3\` + \`/pet\` → \`/pet\`).
|
|
9
|
+
*/
|
|
10
|
+
export function buildUrl(baseUrl, pathTemplate, pathParams = {}) {
|
|
11
|
+
let path = pathTemplate;
|
|
12
|
+
for (const [key, value] of Object.entries(pathParams)) {
|
|
13
|
+
path = path.replaceAll(\`{\${key}}\`, encodeURIComponent(String(value)));
|
|
14
|
+
}
|
|
15
|
+
return baseUrl.replace(/\\/+$/, '') + (path.startsWith('/') ? path : \`/\${path}\`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildQueryString(queryParams = {}) {
|
|
19
|
+
const params = new URLSearchParams();
|
|
20
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
21
|
+
if (value === undefined || value === null) continue;
|
|
22
|
+
for (const v of Array.isArray(value) ? value : [value]) {
|
|
23
|
+
params.append(key, typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return params.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function encodeBody(body, contentType) {
|
|
30
|
+
if (typeof body === 'string') return body;
|
|
31
|
+
if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
32
|
+
return buildQueryString(body);
|
|
33
|
+
}
|
|
34
|
+
return JSON.stringify(body);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read the body, aborting as soon as it exceeds MAX_RESPONSE_BYTES. */
|
|
38
|
+
async function readCapped(response) {
|
|
39
|
+
if (!response.body) return '';
|
|
40
|
+
const reader = response.body.getReader();
|
|
41
|
+
const chunks = [];
|
|
42
|
+
let total = 0;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const { done, value } = await reader.read();
|
|
45
|
+
if (done) break;
|
|
46
|
+
total += value.byteLength;
|
|
47
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
48
|
+
await reader.cancel();
|
|
49
|
+
throw new Error(\`Response body exceeds \${MAX_RESPONSE_BYTES} bytes\`);
|
|
50
|
+
}
|
|
51
|
+
chunks.push(value);
|
|
52
|
+
}
|
|
53
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function executeRequest(toolConfig, args, config = {}) {
|
|
57
|
+
const { baseUrl: configBaseUrl, headers: configHeaders = {} } = config;
|
|
58
|
+
const baseUrl = configBaseUrl || toolConfig.baseUrl;
|
|
59
|
+
if (!baseUrl) {
|
|
60
|
+
throw new Error(\`No base URL configured for tool: \${toolConfig.name}. Set API_BASE_URL.\`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const pathParams = {};
|
|
64
|
+
const queryParams = {};
|
|
65
|
+
const rawHeaderParams = {};
|
|
66
|
+
|
|
67
|
+
for (const param of toolConfig.executionParameters || []) {
|
|
68
|
+
const value = args[param.name];
|
|
69
|
+
if (value === undefined) continue;
|
|
70
|
+
if (param.in === 'path') pathParams[param.name] = value;
|
|
71
|
+
else if (param.in === 'query') queryParams[param.name] = value;
|
|
72
|
+
else if (param.in === 'header') rawHeaderParams[param.name] = value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let url = buildUrl(baseUrl, toolConfig.pathTemplate, pathParams);
|
|
76
|
+
const queryString = buildQueryString(queryParams);
|
|
77
|
+
if (queryString) url += (url.includes('?') ? '&' : '?') + queryString;
|
|
78
|
+
|
|
79
|
+
const method = toolConfig.method.toUpperCase();
|
|
80
|
+
const hasBody = args.requestBody !== undefined && !['GET', 'HEAD', 'OPTIONS'].includes(method);
|
|
81
|
+
const contentType = toolConfig.requestBodyContentType || 'application/json';
|
|
82
|
+
|
|
83
|
+
// configHeaders (auth) always win over spec-defined header params
|
|
84
|
+
const headers = {
|
|
85
|
+
Accept: 'application/json, */*;q=0.8',
|
|
86
|
+
...sanitizeHeaderParams(rawHeaderParams),
|
|
87
|
+
...configHeaders,
|
|
88
|
+
};
|
|
89
|
+
if (hasBody) headers['Content-Type'] = contentType;
|
|
90
|
+
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetch(url, {
|
|
94
|
+
method,
|
|
95
|
+
headers,
|
|
96
|
+
body: hasBody ? encodeBody(args.requestBody, contentType) : undefined,
|
|
97
|
+
// A redirect could carry the Authorization header to another host.
|
|
98
|
+
redirect: 'error',
|
|
99
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err?.name === 'TimeoutError') {
|
|
103
|
+
throw new Error(\`\${method} \${url} timed out after \${REQUEST_TIMEOUT_MS}ms\`);
|
|
104
|
+
}
|
|
105
|
+
throw new Error(\`\${method} \${url} failed: \${err?.cause?.message || err?.message || err}\`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const declaredLength = Number.parseInt(response.headers.get('content-length') || '0', 10);
|
|
109
|
+
if (declaredLength > MAX_RESPONSE_BYTES) {
|
|
110
|
+
throw new Error(\`Response too large: \${declaredLength} bytes (max \${MAX_RESPONSE_BYTES})\`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const text = await readCapped(response);
|
|
114
|
+
let data = text;
|
|
115
|
+
if ((response.headers.get('content-type') || '').includes('json') && text) {
|
|
116
|
+
try {
|
|
117
|
+
data = JSON.parse(text);
|
|
118
|
+
} catch {
|
|
119
|
+
// Mislabelled body — hand the raw text back instead of failing the call.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { status: response.status, statusText: response.statusText, data, ok: response.ok };
|
|
124
|
+
}
|
|
125
|
+
`;
|
|
126
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { DEPENDENCY_VERSIONS, NODE_ENGINE } from '../versions.js';
|
|
2
|
+
|
|
3
|
+
export function generatePackageJson(serverName, tools) {
|
|
4
|
+
return JSON.stringify({
|
|
5
|
+
name: serverName,
|
|
6
|
+
version: '1.0.0',
|
|
7
|
+
description: `MCP server generated from OpenAPI spec (${tools.length} tools)`,
|
|
8
|
+
type: 'module',
|
|
9
|
+
main: 'src/index.js',
|
|
10
|
+
scripts: {
|
|
11
|
+
// Node's own .env loader (22.9+) replaces the dotenv dependency.
|
|
12
|
+
start: 'node --env-file-if-exists=.env src/index.js',
|
|
13
|
+
// `mcp-use dev` imports the default-exported server, hot-reloads it and
|
|
14
|
+
// mounts the Inspector next to the MCP endpoint.
|
|
15
|
+
dev: 'mcp-use dev --entry src/index.js',
|
|
16
|
+
'start:inspector': 'mcp-use start --entry src/index.js --with-inspector',
|
|
17
|
+
},
|
|
18
|
+
dependencies: { ...DEPENDENCY_VERSIONS },
|
|
19
|
+
engines: { node: NODE_ENGINE },
|
|
20
|
+
}, null, 2) + '\n';
|
|
21
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { literal } from '../spec/schema-to-zod.js';
|
|
2
|
+
|
|
3
|
+
/** `src/policy.js` of the generated server: tool policy, host allowlist, header filter. */
|
|
4
|
+
export function generatePolicy(baseUrl) {
|
|
5
|
+
let specHost = '';
|
|
6
|
+
try {
|
|
7
|
+
specHost = baseUrl ? new URL(baseUrl).host : '';
|
|
8
|
+
} catch { /* relative or templated server URL — no default allowlist */ }
|
|
9
|
+
|
|
10
|
+
return `// Runtime security policy for the generated MCP server
|
|
11
|
+
|
|
12
|
+
const BLOCKED_HEADER_NAMES = new Set([
|
|
13
|
+
'authorization', 'cookie', 'set-cookie', 'x-api-key',
|
|
14
|
+
'x-auth-token', 'proxy-authorization', 'www-authenticate',
|
|
15
|
+
'host', 'content-length', 'transfer-encoding', 'connection',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
// The API host the spec declares. With ALLOWED_API_HOSTS unset, only this
|
|
19
|
+
// host may be called, so a tampered API_BASE_URL cannot redirect credentials.
|
|
20
|
+
const SPEC_API_HOST = ${literal(specHost)};
|
|
21
|
+
|
|
22
|
+
const intEnv = (name, fallback) => {
|
|
23
|
+
const n = Number.parseInt(process.env[name] ?? '', 10);
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MAX_RESPONSE_BYTES = intEnv('MAX_RESPONSE_BYTES', 10 * 1024 * 1024);
|
|
28
|
+
export const REQUEST_TIMEOUT_MS = intEnv('REQUEST_TIMEOUT_MS', 30_000);
|
|
29
|
+
|
|
30
|
+
/** Drop credential and hop-by-hop headers a tool argument tries to set. */
|
|
31
|
+
export function sanitizeHeaderParams(headerParams = {}) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of Object.entries(headerParams)) {
|
|
34
|
+
if (!BLOCKED_HEADER_NAMES.has(k.toLowerCase())) out[k] = String(v);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function assertAllowedBaseUrl(baseUrl) {
|
|
40
|
+
const url = new URL(baseUrl);
|
|
41
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
42
|
+
throw new Error(\`Unsupported API protocol: \${url.protocol}\`);
|
|
43
|
+
}
|
|
44
|
+
const configured = (process.env.ALLOWED_API_HOSTS || '')
|
|
45
|
+
.split(',')
|
|
46
|
+
.map(s => s.trim())
|
|
47
|
+
.filter(Boolean);
|
|
48
|
+
const allowedHosts = configured.length > 0 ? configured : [SPEC_API_HOST].filter(Boolean);
|
|
49
|
+
if (allowedHosts.length > 0 && !allowedHosts.includes(url.host)) {
|
|
50
|
+
throw new Error(\`Disallowed API host: \${url.host}. Add it to ALLOWED_API_HOSTS to allow it.\`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function checkToolPolicy(toolConfig) {
|
|
55
|
+
if (!toolConfig.enabledByDefault && process.env.ALLOW_RESTRICTED_TOOLS !== 'true') {
|
|
56
|
+
throw new Error(
|
|
57
|
+
\`Tool '\${toolConfig.name}' is disabled by policy (riskLevel: \${toolConfig.riskLevel}). \` +
|
|
58
|
+
'Set ALLOW_RESTRICTED_TOOLS=true to enable restricted tools.'
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (toolConfig.requiresApproval && process.env.REQUIRE_APPROVALS !== 'false') {
|
|
62
|
+
throw new Error(
|
|
63
|
+
\`Tool '\${toolConfig.name}' requires approval (riskLevel: \${toolConfig.riskLevel}). \` +
|
|
64
|
+
'Set REQUIRE_APPROVALS=false to bypass, or route through an approval handler.'
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { NODE_ENGINE } from '../versions.js';
|
|
2
|
+
|
|
3
|
+
const cell = (s) => String(s).replace(/\|/g, '\\|').replace(/\s+/g, ' ');
|
|
4
|
+
|
|
5
|
+
/** `README.md` of the generated server. */
|
|
6
|
+
export function generateReadme(serverName, tools, specPath, baseUrl, port) {
|
|
7
|
+
const toolList = tools
|
|
8
|
+
.map(t => {
|
|
9
|
+
const desc = t.description.split('\n')[0];
|
|
10
|
+
return `| \`${t.name}\` | ${t.method.toUpperCase()} | ${cell(t.pathTemplate)} | ${t.riskLevel} | ${cell(desc.substring(0, 60))}${desc.length > 60 ? '…' : ''} |`;
|
|
11
|
+
})
|
|
12
|
+
.join('\n');
|
|
13
|
+
|
|
14
|
+
return `# ${serverName}
|
|
15
|
+
|
|
16
|
+
MCP server generated from an OpenAPI specification by [api2ai](https://www.npmjs.com/package/api2ai), built on the [mcp-use](https://mcp-use.com) framework.
|
|
17
|
+
|
|
18
|
+
- 🛠️ **${tools.length} tools** — one per OpenAPI operation, with Zod-validated input
|
|
19
|
+
- 📡 **Streamable HTTP** at \`/mcp\`
|
|
20
|
+
- 🔍 **Inspector** at \`/mcp/inspector\` via \`npm run dev\`
|
|
21
|
+
- 🔐 **Auth** — bearer token or a custom header, never overridable by tool input
|
|
22
|
+
- 🛡️ **Risk policy** — mutating and sensitive tools are off until you opt in
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
26
|
+
Requires Node ${NODE_ENGINE}.
|
|
27
|
+
|
|
28
|
+
\`\`\`bash
|
|
29
|
+
npm install
|
|
30
|
+
cp .env.example .env # then add your API credentials
|
|
31
|
+
npm start # http://localhost:${port}/mcp
|
|
32
|
+
npm run dev # hot reload + Inspector at http://localhost:${port}/mcp/inspector
|
|
33
|
+
\`\`\`
|
|
34
|
+
|
|
35
|
+
## Security
|
|
36
|
+
|
|
37
|
+
**Generation-time classification** — each tool is \`low\`, \`medium\` or \`high\` risk.
|
|
38
|
+
Read-only (\`GET\`/\`HEAD\`/\`OPTIONS\`) operations are \`low\` and enabled.
|
|
39
|
+
Mutating operations are \`medium\` and blocked unless \`ALLOW_RESTRICTED_TOOLS=true\`.
|
|
40
|
+
Anything matching admin, payment, auth, billing or credential patterns is \`high\`.
|
|
41
|
+
|
|
42
|
+
**Runtime policy** — \`checkToolPolicy()\` runs before every call and enforces the risk level and approval settings below.
|
|
43
|
+
|
|
44
|
+
**HTTP hardening** — request timeouts, a streamed response size cap, no redirects, a host allowlist (by default only the spec's API host), and credential headers that tool arguments cannot set.
|
|
45
|
+
|
|
46
|
+
> The Inspector exposes every registered tool. Don't run \`npm run dev\` or \`start:inspector\` on a public interface.
|
|
47
|
+
|
|
48
|
+
## Environment variables
|
|
49
|
+
|
|
50
|
+
| Variable | Description | Default |
|
|
51
|
+
|----------|-------------|---------|
|
|
52
|
+
| \`PORT\` | Server port | ${port} |
|
|
53
|
+
| \`HOST\` | Bind address (\`0.0.0.0\` for containers) | 127.0.0.1 |
|
|
54
|
+
| \`API_BASE_URL\` | Base URL for API requests | ${baseUrl || '(none in spec)'} |
|
|
55
|
+
| \`API_KEY\` | Sent as \`Authorization: Bearer …\` | - |
|
|
56
|
+
| \`API_AUTH_HEADER\` | Custom auth header, \`Header-Name:value\` | - |
|
|
57
|
+
| \`ALLOWED_API_HOSTS\` | Comma-separated API hosts that may be called | the spec's host |
|
|
58
|
+
| \`ALLOW_RESTRICTED_TOOLS\` | Enable medium/high-risk tools | false |
|
|
59
|
+
| \`REQUIRE_APPROVALS\` | Refuse restricted tools until approved | true |
|
|
60
|
+
| \`REQUEST_TIMEOUT_MS\` | Upstream request timeout | 30000 |
|
|
61
|
+
| \`MAX_RESPONSE_BYTES\` | Upstream response size cap | 10485760 |
|
|
62
|
+
| \`MCP_URL\` | Public URL when behind a proxy (its host is allowed) | - |
|
|
63
|
+
| \`ALLOWED_ORIGINS\` | Browser origins allowed to call \`/mcp\` | - |
|
|
64
|
+
|
|
65
|
+
## Connect a client
|
|
66
|
+
|
|
67
|
+
**Claude Code**
|
|
68
|
+
|
|
69
|
+
\`\`\`bash
|
|
70
|
+
claude mcp add --transport http ${serverName} http://localhost:${port}/mcp
|
|
71
|
+
\`\`\`
|
|
72
|
+
|
|
73
|
+
**Claude Desktop, Cursor, and other JSON-configured clients**
|
|
74
|
+
|
|
75
|
+
\`\`\`json
|
|
76
|
+
{
|
|
77
|
+
"mcpServers": {
|
|
78
|
+
"${serverName}": { "url": "http://localhost:${port}/mcp" }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
\`\`\`
|
|
82
|
+
|
|
83
|
+
## Tools
|
|
84
|
+
|
|
85
|
+
| Tool | Method | Path | Risk | Description |
|
|
86
|
+
|------|--------|------|------|-------------|
|
|
87
|
+
${toolList}
|
|
88
|
+
|
|
89
|
+
## Project structure
|
|
90
|
+
|
|
91
|
+
\`\`\`
|
|
92
|
+
${serverName}/
|
|
93
|
+
├── .env / .env.example
|
|
94
|
+
├── package.json
|
|
95
|
+
└── src/
|
|
96
|
+
├── index.js # MCPServer + one server.tool() per operation
|
|
97
|
+
├── http-client.js # Outbound requests (timeouts, size cap, no redirects)
|
|
98
|
+
├── tools-config.js # Method, path, parameters and risk for each tool
|
|
99
|
+
└── policy.js # Tool policy, host allowlist, header filtering
|
|
100
|
+
\`\`\`
|
|
101
|
+
|
|
102
|
+
## Deploy
|
|
103
|
+
|
|
104
|
+
\`\`\`dockerfile
|
|
105
|
+
FROM node:22-alpine
|
|
106
|
+
WORKDIR /app
|
|
107
|
+
COPY package*.json ./
|
|
108
|
+
RUN npm ci --omit=dev
|
|
109
|
+
COPY . .
|
|
110
|
+
ENV NODE_ENV=production HOST=0.0.0.0
|
|
111
|
+
EXPOSE ${port}
|
|
112
|
+
CMD ["node", "src/index.js"]
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
## Source
|
|
116
|
+
|
|
117
|
+
- **OpenAPI spec**: \`${specPath}\`
|
|
118
|
+
- **Generated**: ${new Date().toISOString()}
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
|
123
|
+
`;
|
|
124
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { literal } from '../spec/schema-to-zod.js';
|
|
2
|
+
|
|
3
|
+
function toolRegistration(tool) {
|
|
4
|
+
return `
|
|
5
|
+
server.tool(
|
|
6
|
+
{
|
|
7
|
+
name: ${literal(tool.name)},
|
|
8
|
+
title: ${literal(tool.title)},
|
|
9
|
+
description: ${literal(tool.description)},
|
|
10
|
+
inputSchema: ${tool.zodSchema},
|
|
11
|
+
annotations: ${JSON.stringify(tool.annotations)},
|
|
12
|
+
},
|
|
13
|
+
(params) => runTool(${literal(tool.name)}, params),
|
|
14
|
+
);`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `src/index.js` of the generated server, written against the mcp-use 2.x API. */
|
|
18
|
+
export function generateServerIndex(serverName, tools, baseUrl, port) {
|
|
19
|
+
return `#!/usr/bin/env node
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* ${serverName.replace(/\*\//g, '* /')} — MCP server (${tools.length} tools), generated by api2ai.
|
|
23
|
+
*
|
|
24
|
+
* npm start serve on http://localhost:${port}/mcp
|
|
25
|
+
* npm run dev hot reload + Inspector at /mcp/inspector (mcp-use dev)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { realpathSync } from 'node:fs';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { MCPServer } from 'mcp-use';
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
import { executeRequest } from './http-client.js';
|
|
33
|
+
import { toolConfigMap } from './tools-config.js';
|
|
34
|
+
import { assertAllowedBaseUrl, checkToolPolicy } from './policy.js';
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Configuration
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
const PORT = Number.parseInt(process.env.PORT || '${port}', 10);
|
|
41
|
+
|
|
42
|
+
const apiConfig = {
|
|
43
|
+
baseUrl: process.env.API_BASE_URL || ${baseUrl ? literal(baseUrl) : 'null'},
|
|
44
|
+
headers: {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (process.env.API_KEY) {
|
|
48
|
+
apiConfig.headers['Authorization'] = \`Bearer \${process.env.API_KEY}\`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (process.env.API_AUTH_HEADER) {
|
|
52
|
+
const [key, ...valueParts] = process.env.API_AUTH_HEADER.split(':');
|
|
53
|
+
const value = valueParts.join(':'); // values may contain colons
|
|
54
|
+
if (key && value) apiConfig.headers[key.trim()] = value.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (apiConfig.baseUrl) assertAllowedBaseUrl(apiConfig.baseUrl);
|
|
58
|
+
|
|
59
|
+
// mcp-use matches allowedOrigins/allowedHosts by hostname; accept full URLs too.
|
|
60
|
+
const hostnames = (list) => (list || '')
|
|
61
|
+
.split(',')
|
|
62
|
+
.map(s => s.trim())
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.map(s => { try { return new URL(s).hostname; } catch { return s; } });
|
|
65
|
+
|
|
66
|
+
const allowedOrigins = hostnames(process.env.ALLOWED_ORIGINS);
|
|
67
|
+
const allowedHosts = hostnames(process.env.MCP_URL);
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Server
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
const server = new MCPServer({
|
|
74
|
+
name: ${literal(serverName)},
|
|
75
|
+
version: '1.0.0',
|
|
76
|
+
description: 'MCP server generated from an OpenAPI specification',
|
|
77
|
+
port: PORT,
|
|
78
|
+
...(allowedOrigins.length > 0 && { allowedOrigins }),
|
|
79
|
+
...(allowedHosts.length > 0 && { allowedHosts }),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const errorResult = (message) => ({ isError: true, content: [{ type: 'text', text: message }] });
|
|
83
|
+
|
|
84
|
+
/** Policy check → upstream call → MCP tool result. Shared by every tool. */
|
|
85
|
+
async function runTool(name, params) {
|
|
86
|
+
const toolConfig = toolConfigMap.get(name);
|
|
87
|
+
try {
|
|
88
|
+
checkToolPolicy(toolConfig);
|
|
89
|
+
const result = await executeRequest(toolConfig, params, apiConfig);
|
|
90
|
+
const body = typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2);
|
|
91
|
+
|
|
92
|
+
if (!result.ok) {
|
|
93
|
+
return errorResult(\`Error: \${result.status} \${result.statusText}\\n\${body}\`);
|
|
94
|
+
}
|
|
95
|
+
// Objects and arrays also go out as structuredContent for clients that use it.
|
|
96
|
+
return typeof result.data === 'object' && result.data !== null
|
|
97
|
+
? { content: [{ type: 'text', text: body }], structuredContent: result.data }
|
|
98
|
+
: { content: [{ type: 'text', text: body || \`\${result.status} \${result.statusText}\` }] };
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return errorResult(err?.message || String(err));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ============================================================================
|
|
105
|
+
// Tools
|
|
106
|
+
// ============================================================================
|
|
107
|
+
${tools.map(toolRegistration).join('\n')}
|
|
108
|
+
|
|
109
|
+
export default server;
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Start — only when run directly; \`mcp-use dev\` imports the default export.
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
const isMain = Boolean(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
116
|
+
|
|
117
|
+
if (isMain) {
|
|
118
|
+
const { url } = await server.listen(PORT);
|
|
119
|
+
console.log(\`
|
|
120
|
+
🚀 ${serverName.replace(/[`$\\]/g, '\\$&')} MCP server
|
|
121
|
+
MCP endpoint: \${url}
|
|
122
|
+
Tools: ${tools.length}
|
|
123
|
+
API base: \${apiConfig.baseUrl || 'not configured (set API_BASE_URL)'}
|
|
124
|
+
Policy: ALLOW_RESTRICTED_TOOLS=\${process.env.ALLOW_RESTRICTED_TOOLS || 'false'}, REQUIRE_APPROVALS=\${process.env.REQUIRE_APPROVALS || 'true'}
|
|
125
|
+
\`);
|
|
126
|
+
}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** `src/tools-config.js` of the generated server: per-tool routing + policy data. */
|
|
2
|
+
export function generateToolsConfig(tools) {
|
|
3
|
+
const toolConfigs = tools.map(tool => ({
|
|
4
|
+
name: tool.name,
|
|
5
|
+
description: tool.description,
|
|
6
|
+
method: tool.method,
|
|
7
|
+
pathTemplate: tool.pathTemplate,
|
|
8
|
+
executionParameters: tool.executionParameters,
|
|
9
|
+
requestBodyContentType: tool.requestBodyContentType,
|
|
10
|
+
baseUrl: tool.baseUrl,
|
|
11
|
+
tags: tool.tags,
|
|
12
|
+
operationId: tool.operationId,
|
|
13
|
+
riskLevel: tool.riskLevel,
|
|
14
|
+
requiresApproval: tool.requiresApproval,
|
|
15
|
+
allowedInInspector: tool.allowedInInspector,
|
|
16
|
+
enabledByDefault: tool.enabledByDefault,
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
return `// Tool configurations extracted from the OpenAPI spec
|
|
20
|
+
|
|
21
|
+
export const toolConfigs = ${JSON.stringify(toolConfigs, null, 2)};
|
|
22
|
+
|
|
23
|
+
export const toolConfigMap = new Map(toolConfigs.map(t => [t.name, t]));
|
|
24
|
+
`;
|
|
25
|
+
}
|
package/src/versions.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency versions written into every generated server's package.json.
|
|
3
|
+
*
|
|
4
|
+
* The generated code targets the mcp-use 2.x API (`import { MCPServer } from
|
|
5
|
+
* 'mcp-use'`, `inputSchema`, async `listen()`); a caret on the major keeps
|
|
6
|
+
* `npm install` from pulling a 3.x that the templates were not written for.
|
|
7
|
+
* Bump these together with the templates in ./templates/.
|
|
8
|
+
*/
|
|
9
|
+
export const DEPENDENCY_VERSIONS = {
|
|
10
|
+
'mcp-use': '^2.7.3',
|
|
11
|
+
// mcp-use validates tool input through Standard Schema; zod 4 implements it.
|
|
12
|
+
zod: '^4.6.5',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** mcp-use 2.x declares `engines.node >= 22.22.2`. */
|
|
16
|
+
export const NODE_ENGINE = '>=22.22.2';
|