@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.
Files changed (105) hide show
  1. package/dist/analyzer/dom-parser.d.ts +7 -0
  2. package/dist/analyzer/dom-parser.d.ts.map +1 -1
  3. package/dist/analyzer/dom-parser.js +31 -5
  4. package/dist/analyzer/dom-parser.js.map +1 -1
  5. package/dist/analyzer/goal-crawler.d.ts.map +1 -1
  6. package/dist/analyzer/goal-crawler.js +3 -2
  7. package/dist/analyzer/goal-crawler.js.map +1 -1
  8. package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
  9. package/dist/analyzer/semantic-analyzer.js +3 -2
  10. package/dist/analyzer/semantic-analyzer.js.map +1 -1
  11. package/dist/analyzer/site-crawler.d.ts.map +1 -1
  12. package/dist/analyzer/site-crawler.js +40 -8
  13. package/dist/analyzer/site-crawler.js.map +1 -1
  14. package/dist/emitter/index.d.ts.map +1 -1
  15. package/dist/emitter/index.js +113 -6
  16. package/dist/emitter/index.js.map +1 -1
  17. package/dist/emitter/project-scaffolder.d.ts +13 -0
  18. package/dist/emitter/project-scaffolder.d.ts.map +1 -1
  19. package/dist/emitter/project-scaffolder.js +29 -0
  20. package/dist/emitter/project-scaffolder.js.map +1 -1
  21. package/dist/emitter/python-template-loader.d.ts.map +1 -1
  22. package/dist/emitter/python-template-loader.js +10 -0
  23. package/dist/emitter/python-template-loader.js.map +1 -1
  24. package/dist/emitter/python-templates/server.py.hbs +78 -8
  25. package/dist/emitter/site-templates/browser-manager.ts.hbs +81 -32
  26. package/dist/emitter/site-templates/server-main-http.ts.hbs +25 -7
  27. package/dist/emitter/site-templates/tool-handler-form.ts.hbs +10 -8
  28. package/dist/emitter/template-loader.d.ts.map +1 -1
  29. package/dist/emitter/template-loader.js +9 -0
  30. package/dist/emitter/template-loader.js.map +1 -1
  31. package/dist/emitter/templates/auth-provider.ts.hbs +81 -20
  32. package/dist/emitter/templates/config.ts.hbs +7 -0
  33. package/dist/emitter/templates/env.example.hbs +6 -0
  34. package/dist/emitter/templates/http-executor.ts.hbs +150 -7
  35. package/dist/emitter/templates/server-main-http.ts.hbs +60 -4
  36. package/dist/emitter/templates/tool-handler.ts.hbs +37 -2
  37. package/dist/emitter/templates/tool-test.ts.hbs +9 -1
  38. package/dist/emitter/worker-templates/config.ts.hbs +7 -0
  39. package/dist/emitter/worker-templates/tool-handler.ts.hbs +37 -2
  40. package/dist/emitter/worker-templates/worker.ts.hbs +33 -2
  41. package/dist/generator/spec-generator.d.ts.map +1 -1
  42. package/dist/generator/spec-generator.js +44 -5
  43. package/dist/generator/spec-generator.js.map +1 -1
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +1 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/parser/operation-extractor.d.ts.map +1 -1
  49. package/dist/parser/operation-extractor.js +12 -1
  50. package/dist/parser/operation-extractor.js.map +1 -1
  51. package/dist/parser/overlay-loader.d.ts.map +1 -1
  52. package/dist/parser/overlay-loader.js +11 -2
  53. package/dist/parser/overlay-loader.js.map +1 -1
  54. package/dist/parser/schema-converter.d.ts +13 -2
  55. package/dist/parser/schema-converter.d.ts.map +1 -1
  56. package/dist/parser/schema-converter.js +30 -19
  57. package/dist/parser/schema-converter.js.map +1 -1
  58. package/dist/pricing.d.ts +5 -1
  59. package/dist/pricing.d.ts.map +1 -1
  60. package/dist/pricing.js +7 -3
  61. package/dist/pricing.js.map +1 -1
  62. package/dist/providers/index.d.ts.map +1 -1
  63. package/dist/providers/index.js +6 -21
  64. package/dist/providers/index.js.map +1 -1
  65. package/dist/recorder/browser-recorder.d.ts.map +1 -1
  66. package/dist/recorder/browser-recorder.js +39 -7
  67. package/dist/recorder/browser-recorder.js.map +1 -1
  68. package/dist/site-transformer/selector-healer.d.ts.map +1 -1
  69. package/dist/site-transformer/selector-healer.js +2 -1
  70. package/dist/site-transformer/selector-healer.js.map +1 -1
  71. package/dist/site-transformer/tool-generator.d.ts.map +1 -1
  72. package/dist/site-transformer/tool-generator.js +90 -22
  73. package/dist/site-transformer/tool-generator.js.map +1 -1
  74. package/dist/transformer/auth-detector.d.ts.map +1 -1
  75. package/dist/transformer/auth-detector.js +11 -4
  76. package/dist/transformer/auth-detector.js.map +1 -1
  77. package/dist/transformer/llm-namer.d.ts.map +1 -1
  78. package/dist/transformer/llm-namer.js +35 -10
  79. package/dist/transformer/llm-namer.js.map +1 -1
  80. package/dist/transformer/naming.d.ts.map +1 -1
  81. package/dist/transformer/naming.js +17 -9
  82. package/dist/transformer/naming.js.map +1 -1
  83. package/dist/transformer/resource-builder.d.ts.map +1 -1
  84. package/dist/transformer/resource-builder.js +13 -6
  85. package/dist/transformer/resource-builder.js.map +1 -1
  86. package/dist/transformer/stainless-translator.d.ts +6 -0
  87. package/dist/transformer/stainless-translator.d.ts.map +1 -1
  88. package/dist/transformer/stainless-translator.js +18 -1
  89. package/dist/transformer/stainless-translator.js.map +1 -1
  90. package/dist/transformer/tool-builder.d.ts.map +1 -1
  91. package/dist/transformer/tool-builder.js +111 -13
  92. package/dist/transformer/tool-builder.js.map +1 -1
  93. package/dist/types/index.d.ts +72 -0
  94. package/dist/types/index.d.ts.map +1 -1
  95. package/dist/types/site.d.ts +19 -1
  96. package/dist/types/site.d.ts.map +1 -1
  97. package/dist/utils/model-resolver.d.ts +21 -0
  98. package/dist/utils/model-resolver.d.ts.map +1 -0
  99. package/dist/utils/model-resolver.js +64 -0
  100. package/dist/utils/model-resolver.js.map +1 -0
  101. package/dist/utils/sanitize.d.ts +62 -0
  102. package/dist/utils/sanitize.d.ts.map +1 -1
  103. package/dist/utils/sanitize.js +141 -0
  104. package/dist/utils/sanitize.js.map +1 -1
  105. 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(config: AppConfig): Promise<Record<string, string>> {
25
+ export async function getAuthHeaders(
26
+ config: AppConfig,
27
+ requirement?: AuthRequirement,
28
+ ): Promise<Record<string, string>> {
8
29
  {{else}}
9
- export function getAuthHeaders(config: AppConfig): Record<string, string> {
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 handled in request URL construction
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
- try {
38
- const token = await getAccessToken({
39
- clientId: config.oauth2ClientId ?? '',
40
- clientSecret: config.oauth2ClientSecret,
41
- authorizationUrl: config.oauth2AuthorizationUrl ?? '',
42
- tokenUrl: config.oauth2TokenUrl ?? '',
43
- scopes: [],
44
- redirectUri: config.oauth2RedirectUri ?? 'http://localhost:3000/callback',
45
- });
46
- headers['Authorization'] = `Bearer ${token}`;
47
- } catch {
48
- // Fallback to pre-obtained token
49
- if (config.oauth2Token) {
50
- headers['Authorization'] = `Bearer ${config.oauth2Token}`;
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
- if (options.body && options.contentType) {
92
- headers['Content-Type'] = options.contentType;
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: options.body ? JSON.stringify(options.body) : undefined,
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
- return response.json();
291
+ try {
292
+ return JSON.parse(text);
293
+ } catch {
294
+ return text;
295
+ }
153
296
  }
154
- return response.text();
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
- const sessions = new Map<string, { server: McpServer; transport: StreamableHTTPServerTransport }>();
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.body,
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
- // Tool: {{name}} {{method}} {{pathTemplate}}
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.body,
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}}