@mcpmake/core 0.2.3 → 0.2.5
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/dist/analyzer/dom-parser.d.ts +7 -0
- package/dist/analyzer/dom-parser.d.ts.map +1 -1
- package/dist/analyzer/dom-parser.js +31 -5
- package/dist/analyzer/dom-parser.js.map +1 -1
- package/dist/analyzer/goal-crawler.d.ts.map +1 -1
- package/dist/analyzer/goal-crawler.js +3 -2
- package/dist/analyzer/goal-crawler.js.map +1 -1
- package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
- package/dist/analyzer/semantic-analyzer.js +3 -2
- package/dist/analyzer/semantic-analyzer.js.map +1 -1
- package/dist/analyzer/site-crawler.d.ts.map +1 -1
- package/dist/analyzer/site-crawler.js +40 -8
- package/dist/analyzer/site-crawler.js.map +1 -1
- package/dist/emitter/index.d.ts.map +1 -1
- package/dist/emitter/index.js +113 -6
- package/dist/emitter/index.js.map +1 -1
- package/dist/emitter/project-scaffolder.d.ts +13 -0
- package/dist/emitter/project-scaffolder.d.ts.map +1 -1
- package/dist/emitter/project-scaffolder.js +29 -0
- package/dist/emitter/project-scaffolder.js.map +1 -1
- package/dist/emitter/python-template-loader.d.ts.map +1 -1
- package/dist/emitter/python-template-loader.js +10 -0
- package/dist/emitter/python-template-loader.js.map +1 -1
- package/dist/emitter/python-templates/server.py.hbs +78 -8
- package/dist/emitter/site-templates/browser-manager.ts.hbs +81 -32
- package/dist/emitter/site-templates/server-main-http.ts.hbs +25 -7
- package/dist/emitter/site-templates/tool-handler-form.ts.hbs +10 -8
- package/dist/emitter/template-loader.d.ts.map +1 -1
- package/dist/emitter/template-loader.js +9 -0
- package/dist/emitter/template-loader.js.map +1 -1
- package/dist/emitter/templates/auth-provider.ts.hbs +81 -20
- package/dist/emitter/templates/config.ts.hbs +7 -0
- package/dist/emitter/templates/env.example.hbs +6 -0
- package/dist/emitter/templates/http-executor.ts.hbs +150 -7
- package/dist/emitter/templates/server-main-http.ts.hbs +60 -4
- package/dist/emitter/templates/tool-handler.ts.hbs +37 -2
- package/dist/emitter/templates/tool-test.ts.hbs +9 -1
- package/dist/emitter/worker-templates/config.ts.hbs +7 -0
- package/dist/emitter/worker-templates/tool-handler.ts.hbs +37 -2
- package/dist/emitter/worker-templates/worker.ts.hbs +33 -2
- package/dist/generator/spec-generator.d.ts.map +1 -1
- package/dist/generator/spec-generator.js +44 -5
- package/dist/generator/spec-generator.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/parser/operation-extractor.d.ts.map +1 -1
- package/dist/parser/operation-extractor.js +12 -1
- package/dist/parser/operation-extractor.js.map +1 -1
- package/dist/parser/overlay-loader.d.ts.map +1 -1
- package/dist/parser/overlay-loader.js +11 -2
- package/dist/parser/overlay-loader.js.map +1 -1
- package/dist/parser/schema-converter.d.ts +13 -2
- package/dist/parser/schema-converter.d.ts.map +1 -1
- package/dist/parser/schema-converter.js +30 -19
- package/dist/parser/schema-converter.js.map +1 -1
- package/dist/pricing.d.ts +5 -1
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +7 -3
- package/dist/pricing.js.map +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +6 -21
- package/dist/providers/index.js.map +1 -1
- package/dist/recorder/browser-recorder.d.ts.map +1 -1
- package/dist/recorder/browser-recorder.js +39 -7
- package/dist/recorder/browser-recorder.js.map +1 -1
- package/dist/site-transformer/selector-healer.d.ts.map +1 -1
- package/dist/site-transformer/selector-healer.js +2 -1
- package/dist/site-transformer/selector-healer.js.map +1 -1
- package/dist/site-transformer/tool-generator.d.ts.map +1 -1
- package/dist/site-transformer/tool-generator.js +90 -22
- package/dist/site-transformer/tool-generator.js.map +1 -1
- package/dist/transformer/auth-detector.d.ts.map +1 -1
- package/dist/transformer/auth-detector.js +11 -4
- package/dist/transformer/auth-detector.js.map +1 -1
- package/dist/transformer/llm-namer.d.ts.map +1 -1
- package/dist/transformer/llm-namer.js +35 -10
- package/dist/transformer/llm-namer.js.map +1 -1
- package/dist/transformer/naming.d.ts.map +1 -1
- package/dist/transformer/naming.js +17 -9
- package/dist/transformer/naming.js.map +1 -1
- package/dist/transformer/resource-builder.d.ts.map +1 -1
- package/dist/transformer/resource-builder.js +13 -6
- package/dist/transformer/resource-builder.js.map +1 -1
- package/dist/transformer/stainless-translator.d.ts +6 -0
- package/dist/transformer/stainless-translator.d.ts.map +1 -1
- package/dist/transformer/stainless-translator.js +18 -1
- package/dist/transformer/stainless-translator.js.map +1 -1
- package/dist/transformer/tool-builder.d.ts.map +1 -1
- package/dist/transformer/tool-builder.js +111 -13
- package/dist/transformer/tool-builder.js.map +1 -1
- package/dist/types/index.d.ts +72 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/site.d.ts +19 -1
- package/dist/types/site.d.ts.map +1 -1
- package/dist/utils/model-resolver.d.ts +21 -0
- package/dist/utils/model-resolver.d.ts.map +1 -0
- package/dist/utils/model-resolver.js +64 -0
- package/dist/utils/model-resolver.js.map +1 -0
- package/dist/utils/sanitize.d.ts +62 -0
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +141 -0
- package/dist/utils/sanitize.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,51 +3,88 @@ import type { AppConfig } from './config.js';
|
|
|
3
3
|
import { getAccessToken } from './oauth.js';
|
|
4
4
|
{{/if}}
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Per-operation outbound-auth requirement (D-H2), forwarded from the tool
|
|
8
|
+
* handler. Mirrors the generator's ToolAuthRequirement:
|
|
9
|
+
* - undefined → apply every configured scheme (global default).
|
|
10
|
+
* - { mode: 'public' } → apply NO auth (operation declared `security: []`).
|
|
11
|
+
* - { mode: 'schemes' } → apply ONLY the listed OpenAPI scheme names.
|
|
12
|
+
*/
|
|
13
|
+
export type AuthRequirement =
|
|
14
|
+
| { mode: 'public' }
|
|
15
|
+
| { mode: 'schemes'; schemeNames: string[] };
|
|
16
|
+
|
|
17
|
+
/** Whether the scheme named `name` should be applied for this requirement. */
|
|
18
|
+
function schemeApplies(req: AuthRequirement | undefined, name: string): boolean {
|
|
19
|
+
if (!req) return true; // no per-op requirement → legacy global behavior
|
|
20
|
+
if (req.mode === 'public') return false; // explicit `security: []`
|
|
21
|
+
return req.schemeNames.includes(name);
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
{{#if hasOAuth}}
|
|
7
|
-
export async function getAuthHeaders(
|
|
25
|
+
export async function getAuthHeaders(
|
|
26
|
+
config: AppConfig,
|
|
27
|
+
requirement?: AuthRequirement,
|
|
28
|
+
): Promise<Record<string, string>> {
|
|
8
29
|
{{else}}
|
|
9
|
-
export function getAuthHeaders(
|
|
30
|
+
export function getAuthHeaders(
|
|
31
|
+
config: AppConfig,
|
|
32
|
+
requirement?: AuthRequirement,
|
|
33
|
+
): Record<string, string> {
|
|
10
34
|
{{/if}}
|
|
11
35
|
const headers: Record<string, string> = {};
|
|
12
36
|
|
|
13
37
|
{{#each authSchemes}}
|
|
14
38
|
{{#if (eq type "apiKey")}}
|
|
15
39
|
{{#if (eq in "header")}}
|
|
16
|
-
if (config.apiKey) {
|
|
40
|
+
if (config.apiKey && schemeApplies(requirement, {{{json schemeName}}})) {
|
|
17
41
|
headers['{{headerName}}'] = config.apiKey;
|
|
18
42
|
}
|
|
19
43
|
{{/if}}
|
|
44
|
+
{{#if (eq in "cookie")}}
|
|
45
|
+
// API key sent as a cookie — appended to the Cookie header so it survives
|
|
46
|
+
// alongside any per-operation cookie parameters set by the handler.
|
|
47
|
+
if (config.apiKey && schemeApplies(requirement, {{{json schemeName}}})) {
|
|
48
|
+
const existing = headers['Cookie'];
|
|
49
|
+
const pair = '{{headerName}}=' + encodeURIComponent(config.apiKey);
|
|
50
|
+
headers['Cookie'] = existing ? existing + '; ' + pair : pair;
|
|
51
|
+
}
|
|
52
|
+
{{/if}}
|
|
20
53
|
{{#if (eq in "query")}}
|
|
21
|
-
// API key sent as query parameter
|
|
54
|
+
// API key is sent as a query parameter (`{{headerName}}`). Query-string auth is
|
|
55
|
+
// applied during URL construction (apiKeyQueryName/apiKeyQueryValue in config),
|
|
56
|
+
// not as a header, so there is nothing to add here.
|
|
22
57
|
{{/if}}
|
|
23
58
|
{{/if}}
|
|
24
59
|
{{#if (eq type "http-bearer")}}
|
|
25
|
-
if (config.bearerToken) {
|
|
60
|
+
if (config.bearerToken && schemeApplies(requirement, {{{json schemeName}}})) {
|
|
26
61
|
headers['Authorization'] = `Bearer ${config.bearerToken}`;
|
|
27
62
|
}
|
|
28
63
|
{{/if}}
|
|
29
64
|
{{#if (eq type "http-basic")}}
|
|
30
|
-
if (config.basicUsername && config.basicPassword) {
|
|
65
|
+
if (config.basicUsername && config.basicPassword && schemeApplies(requirement, {{{json schemeName}}})) {
|
|
31
66
|
const encoded = Buffer.from(`${config.basicUsername}:${config.basicPassword}`).toString('base64');
|
|
32
67
|
headers['Authorization'] = `Basic ${encoded}`;
|
|
33
68
|
}
|
|
34
69
|
{{/if}}
|
|
35
70
|
{{#if (eq type "oauth2")}}
|
|
36
71
|
// OAuth2 — use token management with refresh
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
72
|
+
if (schemeApplies(requirement, {{{json schemeName}}})) {
|
|
73
|
+
try {
|
|
74
|
+
const token = await getAccessToken({
|
|
75
|
+
clientId: config.oauth2ClientId ?? '',
|
|
76
|
+
clientSecret: config.oauth2ClientSecret,
|
|
77
|
+
authorizationUrl: config.oauth2AuthorizationUrl ?? '',
|
|
78
|
+
tokenUrl: config.oauth2TokenUrl ?? '',
|
|
79
|
+
scopes: [],
|
|
80
|
+
redirectUri: config.oauth2RedirectUri ?? 'http://localhost:3000/callback',
|
|
81
|
+
});
|
|
82
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
83
|
+
} catch {
|
|
84
|
+
// Fallback to pre-obtained token
|
|
85
|
+
if (config.oauth2Token) {
|
|
86
|
+
headers['Authorization'] = `Bearer ${config.oauth2Token}`;
|
|
87
|
+
}
|
|
51
88
|
}
|
|
52
89
|
}
|
|
53
90
|
{{/if}}
|
|
@@ -55,3 +92,27 @@ export function getAuthHeaders(config: AppConfig): Record<string, string> {
|
|
|
55
92
|
|
|
56
93
|
return headers;
|
|
57
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Query-string auth parameters (apiKey-in-query schemes), keyed by query-param
|
|
98
|
+
* name. Applied by the tool handler during URL construction — a query parameter
|
|
99
|
+
* cannot be sent as a request header (D-H2). Respects the same per-operation
|
|
100
|
+
* requirement as the headers: a public operation gets none, a scheme-scoped one
|
|
101
|
+
* gets only its scheme(s).
|
|
102
|
+
*/
|
|
103
|
+
export function getAuthQueryParams(
|
|
104
|
+
config: AppConfig,
|
|
105
|
+
requirement?: AuthRequirement,
|
|
106
|
+
): Record<string, string> {
|
|
107
|
+
const params: Record<string, string> = {};
|
|
108
|
+
{{#each authSchemes}}
|
|
109
|
+
{{#if (eq type "apiKey")}}
|
|
110
|
+
{{#if (eq in "query")}}
|
|
111
|
+
if (config.apiKey && config.apiKeyQueryName && schemeApplies(requirement, {{{json schemeName}}})) {
|
|
112
|
+
params[config.apiKeyQueryName] = config.apiKey;
|
|
113
|
+
}
|
|
114
|
+
{{/if}}
|
|
115
|
+
{{/if}}
|
|
116
|
+
{{/each}}
|
|
117
|
+
return params;
|
|
118
|
+
}
|
|
@@ -3,6 +3,10 @@ export interface AppConfig {
|
|
|
3
3
|
{{#each authSchemes}}
|
|
4
4
|
{{#if (eq type "apiKey")}}
|
|
5
5
|
apiKey?: string;
|
|
6
|
+
{{#if (eq in "query")}}
|
|
7
|
+
/** Query-parameter name the API key is appended under (apiKey-in-query auth). */
|
|
8
|
+
apiKeyQueryName?: string;
|
|
9
|
+
{{/if}}
|
|
6
10
|
{{/if}}
|
|
7
11
|
{{#if (eq type "http-bearer")}}
|
|
8
12
|
bearerToken?: string;
|
|
@@ -78,6 +82,9 @@ export function loadConfig(): AppConfig {
|
|
|
78
82
|
{{#each authSchemes}}
|
|
79
83
|
{{#if (eq type "apiKey")}}
|
|
80
84
|
apiKey: process.env.{{envVarName}},
|
|
85
|
+
{{#if (eq in "query")}}
|
|
86
|
+
apiKeyQueryName: {{{json headerName}}},
|
|
87
|
+
{{/if}}
|
|
81
88
|
{{/if}}
|
|
82
89
|
{{#if (eq type "http-bearer")}}
|
|
83
90
|
bearerToken: process.env.{{envVarName}},
|
|
@@ -49,4 +49,10 @@ MCP_AUTH_TOKEN=
|
|
|
49
49
|
# — any request can hit any instance). Set to "true" for stateful per-session
|
|
50
50
|
# (Mcp-Session-Id) mode if a client needs resumable streams.
|
|
51
51
|
MCP_STATEFUL=
|
|
52
|
+
# Stateful-session bounds (only used when MCP_STATEFUL=true). The server caps the
|
|
53
|
+
# number of live sessions, evicts ones idle past MCP_SESSION_IDLE_MS, and sweeps
|
|
54
|
+
# on a MCP_SESSION_REAP_MS timer so abandoned sessions can't exhaust memory.
|
|
55
|
+
# MCP_MAX_SESSIONS=1000
|
|
56
|
+
# MCP_SESSION_IDLE_MS=300000
|
|
57
|
+
# MCP_SESSION_REAP_MS=60000
|
|
52
58
|
{{/if}}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAuthHeaders } from './auth.js';
|
|
1
|
+
import { getAuthHeaders, type AuthRequirement } from './auth.js';
|
|
2
2
|
import { traceHeaders } from './trace.js';
|
|
3
3
|
import type { AppConfig } from './config.js';
|
|
4
4
|
|
|
@@ -7,6 +7,14 @@ export interface RequestOptions {
|
|
|
7
7
|
url: string;
|
|
8
8
|
body?: unknown;
|
|
9
9
|
contentType?: string;
|
|
10
|
+
/**
|
|
11
|
+
* How to serialize `body` for the upstream request:
|
|
12
|
+
* - `json` (default): JSON.stringify, Content-Type stays as given.
|
|
13
|
+
* - `form`: URLSearchParams (application/x-www-form-urlencoded).
|
|
14
|
+
* - `multipart`: FormData (the runtime sets multipart Content-Type + boundary;
|
|
15
|
+
* a per-field { __file__, content, filename } object is sent as a file part).
|
|
16
|
+
*/
|
|
17
|
+
bodyEncoding?: 'json' | 'form' | 'multipart';
|
|
10
18
|
headers: Record<string, string>;
|
|
11
19
|
config: AppConfig;
|
|
12
20
|
/**
|
|
@@ -14,6 +22,71 @@ export interface RequestOptions {
|
|
|
14
22
|
* absent and `MCP_IDEMPOTENCY_AUTO=true`, a UUID is generated automatically.
|
|
15
23
|
*/
|
|
16
24
|
idempotencyKey?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Per-operation outbound-auth requirement (D-H2). Forwarded to getAuthHeaders
|
|
27
|
+
* so a public operation (`security: []`) sends no auth and a scheme-scoped
|
|
28
|
+
* operation sends only its scheme(s). Omitted → every configured scheme.
|
|
29
|
+
*/
|
|
30
|
+
authRequirement?: AuthRequirement;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hard per-request deadline (ms). Without it a stalled upstream keeps a tool
|
|
35
|
+
* call (and, in async-task mode, the background promise) alive indefinitely.
|
|
36
|
+
* Override with MCP_REQUEST_TIMEOUT_MS; defaults to 30s.
|
|
37
|
+
*/
|
|
38
|
+
const REQUEST_TIMEOUT_MS = (() => {
|
|
39
|
+
const raw = typeof process !== 'undefined' ? process.env?.MCP_REQUEST_TIMEOUT_MS : undefined;
|
|
40
|
+
const n = raw ? Number(raw) : NaN;
|
|
41
|
+
return Number.isFinite(n) && n > 0 ? n : 30_000;
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Maximum upstream response body size (bytes). A malicious or runaway upstream
|
|
46
|
+
* can otherwise exhaust memory in a single response. Override with
|
|
47
|
+
* MCP_MAX_RESPONSE_BYTES; defaults to 10 MiB.
|
|
48
|
+
*/
|
|
49
|
+
const MAX_RESPONSE_BYTES = (() => {
|
|
50
|
+
const raw = typeof process !== 'undefined' ? process.env?.MCP_MAX_RESPONSE_BYTES : undefined;
|
|
51
|
+
const n = raw ? Number(raw) : NaN;
|
|
52
|
+
return Number.isFinite(n) && n > 0 ? n : 10 * 1024 * 1024;
|
|
53
|
+
})();
|
|
54
|
+
|
|
55
|
+
/** Serialize a request body according to its declared encoding. */
|
|
56
|
+
function serializeBody(
|
|
57
|
+
body: unknown,
|
|
58
|
+
encoding: 'json' | 'form' | 'multipart' | undefined,
|
|
59
|
+
): { body: string | URLSearchParams | FormData; setContentType: boolean } {
|
|
60
|
+
if (encoding === 'form') {
|
|
61
|
+
const params = new URLSearchParams();
|
|
62
|
+
if (body && typeof body === 'object') {
|
|
63
|
+
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
|
|
64
|
+
if (v !== undefined && v !== null) params.append(k, String(v));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { body: params, setContentType: true };
|
|
68
|
+
}
|
|
69
|
+
if (encoding === 'multipart') {
|
|
70
|
+
const form = new FormData();
|
|
71
|
+
if (body && typeof body === 'object') {
|
|
72
|
+
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
|
|
73
|
+
if (v === undefined || v === null) continue;
|
|
74
|
+
if (
|
|
75
|
+
typeof v === 'object' &&
|
|
76
|
+
'__file__' in (v as Record<string, unknown>) &&
|
|
77
|
+
'content' in (v as Record<string, unknown>)
|
|
78
|
+
) {
|
|
79
|
+
const f = v as { content: string; filename?: string };
|
|
80
|
+
form.append(k, new Blob([f.content]), f.filename ?? k);
|
|
81
|
+
} else {
|
|
82
|
+
form.append(k, String(v));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Let the runtime set multipart/form-data with the correct boundary.
|
|
87
|
+
return { body: form, setContentType: false };
|
|
88
|
+
}
|
|
89
|
+
return { body: JSON.stringify(body), setContentType: true };
|
|
17
90
|
}
|
|
18
91
|
|
|
19
92
|
export interface ApiResponse {
|
|
@@ -75,7 +148,7 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
|
|
|
75
148
|
}
|
|
76
149
|
lastRequestTime = Date.now();
|
|
77
150
|
|
|
78
|
-
const authHeaders = await getAuthHeaders(config);
|
|
151
|
+
const authHeaders = await getAuthHeaders(config, options.authRequirement);
|
|
79
152
|
// Header precedence (lowest → highest): auth, runtime default headers
|
|
80
153
|
// (MCP_DEFAULT_HEADERS), W3C Trace Context, then per-call headers.
|
|
81
154
|
// Propagate W3C Trace Context to the upstream API (no-op outside an HTTP
|
|
@@ -88,8 +161,15 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
|
|
|
88
161
|
...options.headers,
|
|
89
162
|
};
|
|
90
163
|
|
|
91
|
-
|
|
92
|
-
|
|
164
|
+
// Serialize the body for its declared encoding. FormData must NOT carry a
|
|
165
|
+
// manual Content-Type — the runtime appends the multipart boundary itself.
|
|
166
|
+
let serializedBody: string | URLSearchParams | FormData | undefined;
|
|
167
|
+
if (options.body !== undefined && options.body !== null) {
|
|
168
|
+
const { body, setContentType } = serializeBody(options.body, options.bodyEncoding);
|
|
169
|
+
serializedBody = body;
|
|
170
|
+
if (setContentType && options.contentType) {
|
|
171
|
+
headers['Content-Type'] = options.contentType;
|
|
172
|
+
}
|
|
93
173
|
}
|
|
94
174
|
|
|
95
175
|
// Idempotency-Key for mutating requests (explicit per-call, or auto when enabled).
|
|
@@ -101,11 +181,16 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
|
|
|
101
181
|
let lastError: Error | undefined;
|
|
102
182
|
|
|
103
183
|
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
|
184
|
+
// Per-attempt hard deadline. AbortError is not retryable — a timed-out
|
|
185
|
+
// upstream should fail fast rather than multiply the wait by the retry count.
|
|
186
|
+
const controller = new AbortController();
|
|
187
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
104
188
|
try {
|
|
105
189
|
const response = await fetch(options.url, {
|
|
106
190
|
method,
|
|
107
191
|
headers,
|
|
108
|
-
body:
|
|
192
|
+
body: serializedBody,
|
|
193
|
+
signal: controller.signal,
|
|
109
194
|
});
|
|
110
195
|
|
|
111
196
|
if (RETRYABLE_STATUS_CODES.includes(response.status) && attempt < config.maxRetries) {
|
|
@@ -134,24 +219,82 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
|
|
|
134
219
|
};
|
|
135
220
|
} catch (error) {
|
|
136
221
|
lastError = error as Error;
|
|
222
|
+
// A request that hit the hard deadline is cancelled, not retried.
|
|
223
|
+
const isAbort =
|
|
224
|
+
error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
|
|
225
|
+
if (isAbort) {
|
|
226
|
+
throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
|
227
|
+
}
|
|
137
228
|
if (error instanceof ApiError && !RETRYABLE_STATUS_CODES.includes(error.status)) {
|
|
138
229
|
throw error;
|
|
139
230
|
}
|
|
140
231
|
if (attempt < config.maxRetries) {
|
|
141
232
|
await sleep(1000 * Math.pow(2, attempt));
|
|
142
233
|
}
|
|
234
|
+
} finally {
|
|
235
|
+
clearTimeout(timer);
|
|
143
236
|
}
|
|
144
237
|
}
|
|
145
238
|
|
|
146
239
|
throw lastError ?? new Error('Request failed after retries');
|
|
147
240
|
}
|
|
148
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Read a response body with a hard byte cap. Streaming the body lets us abort as
|
|
244
|
+
* soon as the cap is exceeded instead of buffering an unbounded (or dishonest
|
|
245
|
+
* Content-Length) payload into memory.
|
|
246
|
+
*/
|
|
247
|
+
async function readBounded(response: Response): Promise<string> {
|
|
248
|
+
const declared = Number(response.headers.get('content-length'));
|
|
249
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
250
|
+
throw new Error(`Response too large (${declared} bytes > ${MAX_RESPONSE_BYTES} cap)`);
|
|
251
|
+
}
|
|
252
|
+
if (!response.body) {
|
|
253
|
+
return response.text();
|
|
254
|
+
}
|
|
255
|
+
const reader = response.body.getReader();
|
|
256
|
+
const chunks: Uint8Array[] = [];
|
|
257
|
+
let total = 0;
|
|
258
|
+
try {
|
|
259
|
+
for (;;) {
|
|
260
|
+
const { done, value } = await reader.read();
|
|
261
|
+
if (done) break;
|
|
262
|
+
if (value) {
|
|
263
|
+
total += value.byteLength;
|
|
264
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
265
|
+
await reader.cancel();
|
|
266
|
+
throw new Error(`Response exceeded ${MAX_RESPONSE_BYTES}-byte cap (truncated)`);
|
|
267
|
+
}
|
|
268
|
+
chunks.push(value);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
} finally {
|
|
272
|
+
reader.releaseLock();
|
|
273
|
+
}
|
|
274
|
+
return new TextDecoder().decode(concatChunks(chunks, total));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function concatChunks(chunks: Uint8Array[], total: number): Uint8Array {
|
|
278
|
+
const out = new Uint8Array(total);
|
|
279
|
+
let offset = 0;
|
|
280
|
+
for (const c of chunks) {
|
|
281
|
+
out.set(c, offset);
|
|
282
|
+
offset += c.byteLength;
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
149
287
|
async function parseResponseBody(response: Response): Promise<unknown> {
|
|
150
288
|
const contentType = response.headers.get('content-type') ?? '';
|
|
289
|
+
const text = await readBounded(response);
|
|
151
290
|
if (contentType.includes('application/json')) {
|
|
152
|
-
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(text);
|
|
293
|
+
} catch {
|
|
294
|
+
return text;
|
|
295
|
+
}
|
|
153
296
|
}
|
|
154
|
-
return
|
|
297
|
+
return text;
|
|
155
298
|
}
|
|
156
299
|
|
|
157
300
|
function sleep(ms: number): Promise<void> {
|
|
@@ -174,8 +174,50 @@ if (transportMode === 'http') {
|
|
|
174
174
|
/* Stateful mode keeps one McpServer + transport PER SESSION, keyed by the
|
|
175
175
|
* Mcp-Session-Id the SDK assigns on `initialize`, so concurrent clients get
|
|
176
176
|
* independent, isolated sessions (a single shared transport cannot multiplex
|
|
177
|
-
* them). Stateless mode builds a fresh server + transport per request.
|
|
178
|
-
|
|
177
|
+
* them). Stateless mode builds a fresh server + transport per request.
|
|
178
|
+
*
|
|
179
|
+
* Sessions are bounded: a client that abandons a session without sending the
|
|
180
|
+
* SDK's close (so `transport.onclose` never fires) would otherwise retain its
|
|
181
|
+
* server/transport graph forever. We cap the live count, stamp a last-access
|
|
182
|
+
* time on every request, evict idle sessions on access and via a periodic
|
|
183
|
+
* reaper, and reject new sessions once at capacity. The limits are overridable
|
|
184
|
+
* via env so an operator can tune them without editing the generated source. */
|
|
185
|
+
interface Session {
|
|
186
|
+
server: McpServer;
|
|
187
|
+
transport: StreamableHTTPServerTransport;
|
|
188
|
+
lastAccess: number;
|
|
189
|
+
}
|
|
190
|
+
const sessions = new Map<string, Session>();
|
|
191
|
+
|
|
192
|
+
function envInt(name: string, fallback: number): number {
|
|
193
|
+
const n = parseInt(process.env[name] ?? '', 10);
|
|
194
|
+
return Number.isInteger(n) && n > 0 ? n : fallback;
|
|
195
|
+
}
|
|
196
|
+
const MAX_SESSIONS = envInt('MCP_MAX_SESSIONS', 1000);
|
|
197
|
+
const SESSION_IDLE_MS = envInt('MCP_SESSION_IDLE_MS', 5 * 60 * 1000);
|
|
198
|
+
const SESSION_REAP_MS = envInt('MCP_SESSION_REAP_MS', 60 * 1000);
|
|
199
|
+
|
|
200
|
+
/* Close + drop a single session (best-effort; never throws). */
|
|
201
|
+
function destroySession(sid: string, session: Session): void {
|
|
202
|
+
sessions.delete(sid);
|
|
203
|
+
void session.transport.close().catch(() => {});
|
|
204
|
+
void session.server.close().catch(() => {});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* Evict every session whose last access is older than the idle window. */
|
|
208
|
+
function evictIdleSessions(now: number): void {
|
|
209
|
+
for (const [sid, session] of sessions) {
|
|
210
|
+
if (now - session.lastAccess > SESSION_IDLE_MS) {
|
|
211
|
+
log('info', 'Evicting idle session', { sid });
|
|
212
|
+
destroySession(sid, session);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* Periodic reaper — sweeps idle sessions even when no traffic arrives.
|
|
218
|
+
* `unref()` so it never keeps the process alive on its own. */
|
|
219
|
+
const reaper = setInterval(() => evictIdleSessions(Date.now()), SESSION_REAP_MS);
|
|
220
|
+
reaper.unref();
|
|
179
221
|
|
|
180
222
|
function isInitializeRequest(body: unknown): boolean {
|
|
181
223
|
return (
|
|
@@ -200,26 +242,39 @@ if (transportMode === 'http') {
|
|
|
200
242
|
);
|
|
201
243
|
await runWithTrace(trace, async () => {
|
|
202
244
|
if (stateful) {
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
// Sweep idle sessions on every access so an idle map is reclaimed even
|
|
247
|
+
// between reaper ticks (and before a capacity check can spuriously fail).
|
|
248
|
+
evictIdleSessions(now);
|
|
203
249
|
const sessionId = headerValue(req.headers['mcp-session-id']);
|
|
204
250
|
const existing = sessionId ? sessions.get(sessionId) : undefined;
|
|
205
251
|
if (existing) {
|
|
252
|
+
existing.lastAccess = now;
|
|
206
253
|
await existing.transport.handleRequest(req, res, parsedBody);
|
|
207
254
|
return;
|
|
208
255
|
}
|
|
209
256
|
if (!sessionId && isInitializeRequest(parsedBody)) {
|
|
257
|
+
// Reject new sessions once at capacity rather than growing unbounded.
|
|
258
|
+
if (sessions.size >= MAX_SESSIONS) {
|
|
259
|
+
log('error', 'Session capacity reached, rejecting initialize', {
|
|
260
|
+
maxSessions: MAX_SESSIONS,
|
|
261
|
+
});
|
|
262
|
+
rpcError(res, null, -32000, 'Server at session capacity — retry later');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
210
265
|
// New session: create a dedicated server + transport and register it
|
|
211
266
|
// under the session id the SDK generates during initialize.
|
|
212
267
|
const server = createMcpServer(config);
|
|
213
268
|
const transport = new StreamableHTTPServerTransport({
|
|
214
269
|
sessionIdGenerator: () => crypto.randomUUID(),
|
|
215
270
|
onsessioninitialized: (sid: string) => {
|
|
216
|
-
sessions.set(sid, { server, transport });
|
|
271
|
+
sessions.set(sid, { server, transport, lastAccess: Date.now() });
|
|
217
272
|
},
|
|
218
273
|
});
|
|
219
274
|
transport.onclose = () => {
|
|
220
275
|
const sid = transport.sessionId;
|
|
221
276
|
if (sid) sessions.delete(sid);
|
|
222
|
-
void server.close();
|
|
277
|
+
void server.close().catch(() => {});
|
|
223
278
|
};
|
|
224
279
|
await server.connect(transport);
|
|
225
280
|
await transport.handleRequest(req, res, parsedBody);
|
|
@@ -412,6 +467,7 @@ if (transportMode === 'http') {
|
|
|
412
467
|
process.on('SIGTERM', () => {
|
|
413
468
|
log('info', 'SIGTERM received, shutting down');
|
|
414
469
|
isReady = false;
|
|
470
|
+
clearInterval(reaper);
|
|
415
471
|
|
|
416
472
|
httpServer.close(async () => {
|
|
417
473
|
try {
|
|
@@ -3,6 +3,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
3
3
|
import { executeRequest } from '../http.js';
|
|
4
4
|
import { applyJqFilter } from '../response-filter.js';
|
|
5
5
|
import type { AppConfig } from '../config.js';
|
|
6
|
+
{{#if hasQueryApiKey}}
|
|
7
|
+
import { getAuthQueryParams } from '../auth.js';
|
|
8
|
+
{{/if}}
|
|
6
9
|
{{#if isAsync}}
|
|
7
10
|
import { createTask, updateTask } from '../task-manager.js';
|
|
8
11
|
{{/if}}
|
|
@@ -36,18 +39,30 @@ const outputSchema = {{{outputSchemaCode}}};
|
|
|
36
39
|
async function runRequest(rawInput: Record<string, unknown>, config: AppConfig): Promise<unknown> {
|
|
37
40
|
// Pull off the per-call control args so they are never forwarded upstream.
|
|
38
41
|
const { jq_filter: jqFilter, idempotency_key: idempotencyKey, ...input } = rawInput;
|
|
42
|
+
{{#if hasQueryApiKey}}
|
|
43
|
+
let url = buildUrl(input, config.baseUrl);
|
|
44
|
+
// apiKey-in-query auth: append the key as a query parameter (it cannot be sent
|
|
45
|
+
// as a header). Respects the per-operation security requirement (D-H2).
|
|
46
|
+
url = appendQueryParams(url, getAuthQueryParams(config, {{#if authRequirement}}{{{json authRequirement}}}{{else}}undefined{{/if}}));
|
|
47
|
+
{{else}}
|
|
39
48
|
const url = buildUrl(input, config.baseUrl);
|
|
49
|
+
{{/if}}
|
|
40
50
|
|
|
41
51
|
const response = await executeRequest({
|
|
42
52
|
method: '{{method}}',
|
|
43
53
|
url,
|
|
44
54
|
{{#if hasRequestBody}}
|
|
45
|
-
body: input.
|
|
55
|
+
body: input.{{bodyInputKey}},
|
|
46
56
|
contentType: '{{requestBodyContentType}}',
|
|
57
|
+
bodyEncoding: '{{bodyEncoding}}',
|
|
47
58
|
{{/if}}
|
|
48
|
-
headers:
|
|
59
|
+
headers: buildHeaders(input),
|
|
49
60
|
config,
|
|
50
61
|
idempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined,
|
|
62
|
+
{{#if authRequirement}}
|
|
63
|
+
// Per-operation outbound-auth requirement derived from OpenAPI `security` (D-H2).
|
|
64
|
+
authRequirement: {{{json authRequirement}}},
|
|
65
|
+
{{/if}}
|
|
51
66
|
});
|
|
52
67
|
|
|
53
68
|
let result: unknown = response.data;
|
|
@@ -140,3 +155,23 @@ function buildUrl(
|
|
|
140
155
|
): string {
|
|
141
156
|
{{{buildUrlBody}}}
|
|
142
157
|
}
|
|
158
|
+
|
|
159
|
+
// Build the upstream header/cookie set from the tool input. Header and cookie
|
|
160
|
+
// parameters are sent under their original API names (see paramMappings).
|
|
161
|
+
function buildHeaders(params: Record<string, unknown>): Record<string, string> {
|
|
162
|
+
{{{buildHeadersBody}}}
|
|
163
|
+
}
|
|
164
|
+
{{#if hasQueryApiKey}}
|
|
165
|
+
|
|
166
|
+
// Append extra query parameters (apiKey-in-query auth) onto an already-built URL,
|
|
167
|
+
// preserving any existing query string and URL-encoding name and value.
|
|
168
|
+
function appendQueryParams(url: string, extra: Record<string, string>): string {
|
|
169
|
+
const keys = Object.keys(extra);
|
|
170
|
+
if (keys.length === 0) return url;
|
|
171
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
172
|
+
const qs = keys
|
|
173
|
+
.map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(extra[k]))
|
|
174
|
+
.join('&');
|
|
175
|
+
return url + sep + qs;
|
|
176
|
+
}
|
|
177
|
+
{{/if}}
|
|
@@ -25,7 +25,15 @@ function capture(): { name: string; def: Record<string, unknown>; handler: unkno
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
describe('{{name}}', () => {
|
|
28
|
-
//
|
|
28
|
+
// Source operation metadata. Serialized via JSON.stringify on the builder side
|
|
29
|
+
// so an untrusted OpenAPI path (which can contain newlines/quotes) can never
|
|
30
|
+
// escape a comment or string literal into module-level code.
|
|
31
|
+
const operation = {{{operationMeta}}};
|
|
32
|
+
|
|
33
|
+
it('targets the source operation', () => {
|
|
34
|
+
expect(operation.method).toBe('{{method}}');
|
|
35
|
+
expect(typeof operation.path).toBe('string');
|
|
36
|
+
});
|
|
29
37
|
|
|
30
38
|
it('registers exactly one tool under its name', () => {
|
|
31
39
|
const calls = capture();
|
|
@@ -8,6 +8,10 @@ export interface AppConfig {
|
|
|
8
8
|
{{#each authSchemes}}
|
|
9
9
|
{{#if (eq type "apiKey")}}
|
|
10
10
|
apiKey?: string;
|
|
11
|
+
{{#if (eq in "query")}}
|
|
12
|
+
/** Query-parameter name the API key is appended under (apiKey-in-query auth). */
|
|
13
|
+
apiKeyQueryName?: string;
|
|
14
|
+
{{/if}}
|
|
11
15
|
{{/if}}
|
|
12
16
|
{{#if (eq type "http-bearer")}}
|
|
13
17
|
bearerToken?: string;
|
|
@@ -69,6 +73,9 @@ export function loadConfig(env: EnvLike): AppConfig {
|
|
|
69
73
|
{{#each authSchemes}}
|
|
70
74
|
{{#if (eq type "apiKey")}}
|
|
71
75
|
apiKey: env.{{envVarName}},
|
|
76
|
+
{{#if (eq in "query")}}
|
|
77
|
+
apiKeyQueryName: {{{json headerName}}},
|
|
78
|
+
{{/if}}
|
|
72
79
|
{{/if}}
|
|
73
80
|
{{#if (eq type "http-bearer")}}
|
|
74
81
|
bearerToken: env.{{envVarName}},
|
|
@@ -2,6 +2,9 @@ import { z } from 'zod';
|
|
|
2
2
|
import { executeRequest } from '../http.js';
|
|
3
3
|
import { applyJqFilter } from '../response-filter.js';
|
|
4
4
|
import type { AppConfig } from '../config.js';
|
|
5
|
+
{{#if hasQueryApiKey}}
|
|
6
|
+
import { getAuthQueryParams } from '../auth.js';
|
|
7
|
+
{{/if}}
|
|
5
8
|
|
|
6
9
|
// Per-call control arguments shared by every generated tool. Optional and
|
|
7
10
|
// stripped before the upstream request, so omitting them is a no-op.
|
|
@@ -70,18 +73,30 @@ export async function handler(
|
|
|
70
73
|
async function runRequest(rawInput: Record<string, unknown>, config: AppConfig): Promise<unknown> {
|
|
71
74
|
// Pull off the per-call control args so they are never forwarded upstream.
|
|
72
75
|
const { jq_filter: jqFilter, idempotency_key: idempotencyKey, ...input } = rawInput;
|
|
76
|
+
{{#if hasQueryApiKey}}
|
|
77
|
+
let url = buildUrl(input, config.baseUrl);
|
|
78
|
+
// apiKey-in-query auth: append the key as a query parameter (it cannot be sent
|
|
79
|
+
// as a header). Respects the per-operation security requirement (D-H2).
|
|
80
|
+
url = appendQueryParams(url, getAuthQueryParams(config, {{#if authRequirement}}{{{json authRequirement}}}{{else}}undefined{{/if}}));
|
|
81
|
+
{{else}}
|
|
73
82
|
const url = buildUrl(input, config.baseUrl);
|
|
83
|
+
{{/if}}
|
|
74
84
|
|
|
75
85
|
const response = await executeRequest({
|
|
76
86
|
method: '{{method}}',
|
|
77
87
|
url,
|
|
78
88
|
{{#if hasRequestBody}}
|
|
79
|
-
body: input.
|
|
89
|
+
body: input.{{bodyInputKey}},
|
|
80
90
|
contentType: '{{requestBodyContentType}}',
|
|
91
|
+
bodyEncoding: '{{bodyEncoding}}',
|
|
81
92
|
{{/if}}
|
|
82
|
-
headers:
|
|
93
|
+
headers: buildHeaders(input),
|
|
83
94
|
config,
|
|
84
95
|
idempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined,
|
|
96
|
+
{{#if authRequirement}}
|
|
97
|
+
// Per-operation outbound-auth requirement derived from OpenAPI `security` (D-H2).
|
|
98
|
+
authRequirement: {{{json authRequirement}}},
|
|
99
|
+
{{/if}}
|
|
85
100
|
});
|
|
86
101
|
|
|
87
102
|
let result: unknown = response.data;
|
|
@@ -101,3 +116,23 @@ async function runRequest(rawInput: Record<string, unknown>, config: AppConfig):
|
|
|
101
116
|
function buildUrl(params: Record<string, unknown>, baseUrl: string): string {
|
|
102
117
|
{{{buildUrlBody}}}
|
|
103
118
|
}
|
|
119
|
+
|
|
120
|
+
// Build the upstream header/cookie set from the tool input. Header and cookie
|
|
121
|
+
// parameters are sent under their original API names (see paramMappings).
|
|
122
|
+
function buildHeaders(params: Record<string, unknown>): Record<string, string> {
|
|
123
|
+
{{{buildHeadersBody}}}
|
|
124
|
+
}
|
|
125
|
+
{{#if hasQueryApiKey}}
|
|
126
|
+
|
|
127
|
+
// Append extra query parameters (apiKey-in-query auth) onto an already-built URL,
|
|
128
|
+
// preserving any existing query string and URL-encoding name and value.
|
|
129
|
+
function appendQueryParams(url: string, extra: Record<string, string>): string {
|
|
130
|
+
const keys = Object.keys(extra);
|
|
131
|
+
if (keys.length === 0) return url;
|
|
132
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
133
|
+
const qs = keys
|
|
134
|
+
.map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(extra[k]))
|
|
135
|
+
.join('&');
|
|
136
|
+
return url + sep + qs;
|
|
137
|
+
}
|
|
138
|
+
{{/if}}
|