@mcpmake/core 0.2.4 → 0.3.0

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 (184) hide show
  1. package/dist/analyzer/dom-parser.d.ts +35 -0
  2. package/dist/analyzer/dom-parser.d.ts.map +1 -1
  3. package/dist/analyzer/dom-parser.js +107 -6
  4. package/dist/analyzer/dom-parser.js.map +1 -1
  5. package/dist/analyzer/goal-crawler.d.ts +2 -1
  6. package/dist/analyzer/goal-crawler.d.ts.map +1 -1
  7. package/dist/analyzer/goal-crawler.js +66 -17
  8. package/dist/analyzer/goal-crawler.js.map +1 -1
  9. package/dist/analyzer/semantic-analyzer.d.ts +1 -1
  10. package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
  11. package/dist/analyzer/semantic-analyzer.js +15 -16
  12. package/dist/analyzer/semantic-analyzer.js.map +1 -1
  13. package/dist/analyzer/site-crawler.d.ts.map +1 -1
  14. package/dist/analyzer/site-crawler.js +122 -16
  15. package/dist/analyzer/site-crawler.js.map +1 -1
  16. package/dist/config/mcpmake-config.d.ts.map +1 -1
  17. package/dist/config/mcpmake-config.js +5 -0
  18. package/dist/config/mcpmake-config.js.map +1 -1
  19. package/dist/emitter/code-writer.d.ts +1 -0
  20. package/dist/emitter/code-writer.d.ts.map +1 -1
  21. package/dist/emitter/code-writer.js +79 -12
  22. package/dist/emitter/code-writer.js.map +1 -1
  23. package/dist/emitter/index.d.ts +8 -0
  24. package/dist/emitter/index.d.ts.map +1 -1
  25. package/dist/emitter/index.js +113 -6
  26. package/dist/emitter/index.js.map +1 -1
  27. package/dist/emitter/project-scaffolder.d.ts +13 -0
  28. package/dist/emitter/project-scaffolder.d.ts.map +1 -1
  29. package/dist/emitter/project-scaffolder.js +29 -0
  30. package/dist/emitter/project-scaffolder.js.map +1 -1
  31. package/dist/emitter/python-template-loader.d.ts.map +1 -1
  32. package/dist/emitter/python-template-loader.js +10 -0
  33. package/dist/emitter/python-template-loader.js.map +1 -1
  34. package/dist/emitter/python-templates/dockerfile.hbs +4 -1
  35. package/dist/emitter/python-templates/server.py.hbs +82 -8
  36. package/dist/emitter/site-scaffolder.d.ts.map +1 -1
  37. package/dist/emitter/site-scaffolder.js +28 -1
  38. package/dist/emitter/site-scaffolder.js.map +1 -1
  39. package/dist/emitter/site-templates/browser-manager.ts.hbs +104 -34
  40. package/dist/emitter/site-templates/dockerfile.hbs +13 -4
  41. package/dist/emitter/site-templates/env.example.hbs +8 -0
  42. package/dist/emitter/site-templates/server-main-http.ts.hbs +77 -6
  43. package/dist/emitter/site-templates/telemetry.ts.hbs +117 -0
  44. package/dist/emitter/site-templates/tool-handler-form.ts.hbs +10 -8
  45. package/dist/emitter/template-loader.d.ts.map +1 -1
  46. package/dist/emitter/template-loader.js +9 -0
  47. package/dist/emitter/template-loader.js.map +1 -1
  48. package/dist/emitter/templates/auth-provider.ts.hbs +86 -21
  49. package/dist/emitter/templates/config.ts.hbs +29 -0
  50. package/dist/emitter/templates/dockerfile.hbs +12 -4
  51. package/dist/emitter/templates/env.example.hbs +6 -0
  52. package/dist/emitter/templates/http-executor.ts.hbs +150 -7
  53. package/dist/emitter/templates/oauth.ts.hbs +19 -65
  54. package/dist/emitter/templates/server-main-http.ts.hbs +93 -12
  55. package/dist/emitter/templates/task-handlers.ts.hbs +40 -1
  56. package/dist/emitter/templates/tool-handler.ts.hbs +37 -2
  57. package/dist/emitter/templates/tool-test.ts.hbs +9 -1
  58. package/dist/emitter/worker-templates/config.ts.hbs +7 -0
  59. package/dist/emitter/worker-templates/tool-handler.ts.hbs +37 -2
  60. package/dist/emitter/worker-templates/worker.ts.hbs +119 -25
  61. package/dist/generator/spec-generator.d.ts.map +1 -1
  62. package/dist/generator/spec-generator.js +9 -54
  63. package/dist/generator/spec-generator.js.map +1 -1
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +1 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/llm/anthropic-provider.d.ts +14 -0
  69. package/dist/llm/anthropic-provider.d.ts.map +1 -0
  70. package/dist/llm/anthropic-provider.js +50 -0
  71. package/dist/llm/anthropic-provider.js.map +1 -0
  72. package/dist/llm/index.d.ts +19 -0
  73. package/dist/llm/index.d.ts.map +1 -0
  74. package/dist/llm/index.js +73 -0
  75. package/dist/llm/index.js.map +1 -0
  76. package/dist/llm/openai-provider.d.ts +46 -0
  77. package/dist/llm/openai-provider.d.ts.map +1 -0
  78. package/dist/llm/openai-provider.js +221 -0
  79. package/dist/llm/openai-provider.js.map +1 -0
  80. package/dist/llm/types.d.ts +61 -0
  81. package/dist/llm/types.d.ts.map +1 -0
  82. package/dist/llm/types.js +16 -0
  83. package/dist/llm/types.js.map +1 -0
  84. package/dist/parser/har-filter.d.ts +7 -0
  85. package/dist/parser/har-filter.d.ts.map +1 -1
  86. package/dist/parser/har-filter.js +107 -1
  87. package/dist/parser/har-filter.js.map +1 -1
  88. package/dist/parser/index.d.ts +1 -1
  89. package/dist/parser/index.d.ts.map +1 -1
  90. package/dist/parser/index.js +1 -1
  91. package/dist/parser/index.js.map +1 -1
  92. package/dist/parser/openapi-loader.d.ts.map +1 -1
  93. package/dist/parser/openapi-loader.js +36 -2
  94. package/dist/parser/openapi-loader.js.map +1 -1
  95. package/dist/parser/operation-extractor.d.ts.map +1 -1
  96. package/dist/parser/operation-extractor.js +12 -1
  97. package/dist/parser/operation-extractor.js.map +1 -1
  98. package/dist/parser/overlay-loader.d.ts.map +1 -1
  99. package/dist/parser/overlay-loader.js +11 -2
  100. package/dist/parser/overlay-loader.js.map +1 -1
  101. package/dist/parser/postman-loader.d.ts.map +1 -1
  102. package/dist/parser/postman-loader.js +6 -1
  103. package/dist/parser/postman-loader.js.map +1 -1
  104. package/dist/parser/schema-converter.d.ts +13 -2
  105. package/dist/parser/schema-converter.d.ts.map +1 -1
  106. package/dist/parser/schema-converter.js +64 -42
  107. package/dist/parser/schema-converter.js.map +1 -1
  108. package/dist/plugins/loader.d.ts +5 -1
  109. package/dist/plugins/loader.d.ts.map +1 -1
  110. package/dist/plugins/loader.js +39 -10
  111. package/dist/plugins/loader.js.map +1 -1
  112. package/dist/pricing.d.ts +5 -1
  113. package/dist/pricing.d.ts.map +1 -1
  114. package/dist/pricing.js +7 -3
  115. package/dist/pricing.js.map +1 -1
  116. package/dist/providers/index.d.ts.map +1 -1
  117. package/dist/providers/index.js +6 -21
  118. package/dist/providers/index.js.map +1 -1
  119. package/dist/recorder/browser-recorder.d.ts.map +1 -1
  120. package/dist/recorder/browser-recorder.js +89 -12
  121. package/dist/recorder/browser-recorder.js.map +1 -1
  122. package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
  123. package/dist/rescan/rescan-scheduler.js +22 -7
  124. package/dist/rescan/rescan-scheduler.js.map +1 -1
  125. package/dist/site-transformer/selector-healer.d.ts +1 -1
  126. package/dist/site-transformer/selector-healer.d.ts.map +1 -1
  127. package/dist/site-transformer/selector-healer.js +17 -18
  128. package/dist/site-transformer/selector-healer.js.map +1 -1
  129. package/dist/site-transformer/tool-generator.d.ts.map +1 -1
  130. package/dist/site-transformer/tool-generator.js +90 -22
  131. package/dist/site-transformer/tool-generator.js.map +1 -1
  132. package/dist/transformer/auth-detector.d.ts.map +1 -1
  133. package/dist/transformer/auth-detector.js +24 -5
  134. package/dist/transformer/auth-detector.js.map +1 -1
  135. package/dist/transformer/har-schema-inferrer.d.ts.map +1 -1
  136. package/dist/transformer/har-schema-inferrer.js +2 -0
  137. package/dist/transformer/har-schema-inferrer.js.map +1 -1
  138. package/dist/transformer/har-to-operations.d.ts +1 -0
  139. package/dist/transformer/har-to-operations.d.ts.map +1 -1
  140. package/dist/transformer/har-to-operations.js +42 -10
  141. package/dist/transformer/har-to-operations.js.map +1 -1
  142. package/dist/transformer/llm-namer.d.ts.map +1 -1
  143. package/dist/transformer/llm-namer.js +10 -13
  144. package/dist/transformer/llm-namer.js.map +1 -1
  145. package/dist/transformer/naming.d.ts.map +1 -1
  146. package/dist/transformer/naming.js +16 -8
  147. package/dist/transformer/naming.js.map +1 -1
  148. package/dist/transformer/resource-builder.d.ts.map +1 -1
  149. package/dist/transformer/resource-builder.js +13 -6
  150. package/dist/transformer/resource-builder.js.map +1 -1
  151. package/dist/transformer/stainless-translator.d.ts +6 -0
  152. package/dist/transformer/stainless-translator.d.ts.map +1 -1
  153. package/dist/transformer/stainless-translator.js +18 -1
  154. package/dist/transformer/stainless-translator.js.map +1 -1
  155. package/dist/transformer/tool-builder.d.ts.map +1 -1
  156. package/dist/transformer/tool-builder.js +141 -14
  157. package/dist/transformer/tool-builder.js.map +1 -1
  158. package/dist/types/index.d.ts +79 -0
  159. package/dist/types/index.d.ts.map +1 -1
  160. package/dist/types/site.d.ts +19 -1
  161. package/dist/types/site.d.ts.map +1 -1
  162. package/dist/utils/fail.d.ts.map +1 -1
  163. package/dist/utils/fail.js +22 -4
  164. package/dist/utils/fail.js.map +1 -1
  165. package/dist/utils/json-extract.d.ts +21 -0
  166. package/dist/utils/json-extract.d.ts.map +1 -0
  167. package/dist/utils/json-extract.js +67 -0
  168. package/dist/utils/json-extract.js.map +1 -0
  169. package/dist/utils/model-resolver.d.ts.map +1 -1
  170. package/dist/utils/model-resolver.js +6 -0
  171. package/dist/utils/model-resolver.js.map +1 -1
  172. package/dist/utils/sanitize.d.ts +63 -1
  173. package/dist/utils/sanitize.d.ts.map +1 -1
  174. package/dist/utils/sanitize.js +147 -2
  175. package/dist/utils/sanitize.js.map +1 -1
  176. package/dist/utils/ssrf-guard.d.ts +10 -0
  177. package/dist/utils/ssrf-guard.d.ts.map +1 -0
  178. package/dist/utils/ssrf-guard.js +130 -0
  179. package/dist/utils/ssrf-guard.js.map +1 -0
  180. package/dist/utils/watcher.d.ts +17 -1
  181. package/dist/utils/watcher.d.ts.map +1 -1
  182. package/dist/utils/watcher.js +53 -21
  183. package/dist/utils/watcher.js.map +1 -1
  184. package/package.json +2 -1
@@ -27,6 +27,11 @@ function log(level: 'info' | 'error', msg: string, extra?: Record<string, unknow
27
27
  process.stderr.write(JSON.stringify(entry) + '\n');
28
28
  }
29
29
 
30
+ /* Local/dev escape hatch: when MCP_AUTH_TOKEN is unset, authenticated routes
31
+ * stay closed (401) UNLESS the operator explicitly sets this to `true`. Matches
32
+ * the worker server's MCP_ALLOW_UNAUTHENTICATED so both targets behave alike. */
33
+ const ALLOW_UNAUTHENTICATED = process.env.MCP_ALLOW_UNAUTHENTICATED === 'true';
34
+
30
35
  /* ── MCP 2026-07-28 ─────────────────────────────────────────────────────
31
36
  * This server is being migrated toward the 2026-07-28 spec revision (RC).
32
37
  * Implemented here (additive, works against the current SDK):
@@ -42,11 +47,14 @@ const PROTOCOL_REVISION = '2026-07-28'; // VERIFY against the final spec
42
47
 
43
48
  /* ── Bearer authentication ──────────────────────────────────────────────
44
49
  * Constant-time check that the request presents the expected bearer token via
45
- * the `Authorization: Bearer <token>` header. Returns true when no token is
46
- * configured (auth disabled). The token is never accepted from the query
47
- * string — URLs leak through logs, history, and referrers. */
50
+ * the `Authorization: Bearer <token>` header. The token is never accepted from
51
+ * the query string URLs leak through logs, history, and referrers.
52
+ *
53
+ * Fails CLOSED: when MCP_AUTH_TOKEN is unset, every authenticated route is
54
+ * denied unless the operator explicitly opts in with MCP_ALLOW_UNAUTHENTICATED=
55
+ * true (a local/dev escape hatch — see ALLOW_UNAUTHENTICATED below). */
48
56
  function isAuthorized(req: http.IncomingMessage, expected: string | undefined): boolean {
49
- if (!expected) return true; // auth disabled when MCP_AUTH_TOKEN is unset
57
+ if (!expected) return ALLOW_UNAUTHENTICATED; // fail closed unless explicitly opted in
50
58
 
51
59
  const header = req.headers.authorization;
52
60
  if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
@@ -166,6 +174,22 @@ if (transportMode === 'http') {
166
174
  }
167
175
  const allowedOrigin = process.env.ALLOWED_ORIGIN;
168
176
  const authToken = process.env.MCP_AUTH_TOKEN;
177
+ /* Fail closed: with no MCP_AUTH_TOKEN the server denies every authenticated
178
+ * route (401) unless the operator opted in via MCP_ALLOW_UNAUTHENTICATED=true.
179
+ * Warn loudly, once, when running wide open so it is never a silent default. */
180
+ if (!authToken) {
181
+ if (ALLOW_UNAUTHENTICATED) {
182
+ console.error(
183
+ 'WARNING: MCP_AUTH_TOKEN is not set and MCP_ALLOW_UNAUTHENTICATED=true — ' +
184
+ 'the server is running UNAUTHENTICATED. Do not use this in production.',
185
+ );
186
+ } else {
187
+ console.error(
188
+ 'MCP_AUTH_TOKEN is not set — authenticated routes will return 401. ' +
189
+ 'Set MCP_AUTH_TOKEN, or MCP_ALLOW_UNAUTHENTICATED=true for local/dev only.',
190
+ );
191
+ }
192
+ }
169
193
  /* Stateless by default (2026-07-28). MCP_STATEFUL=true keeps Mcp-Session-Id
170
194
  * sessions during the migration / for clients that need resumable streams. */
171
195
  const stateful = process.env.MCP_STATEFUL === 'true';
@@ -174,8 +198,50 @@ if (transportMode === 'http') {
174
198
  /* Stateful mode keeps one McpServer + transport PER SESSION, keyed by the
175
199
  * Mcp-Session-Id the SDK assigns on `initialize`, so concurrent clients get
176
200
  * 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 }>();
201
+ * them). Stateless mode builds a fresh server + transport per request.
202
+ *
203
+ * Sessions are bounded: a client that abandons a session without sending the
204
+ * SDK's close (so `transport.onclose` never fires) would otherwise retain its
205
+ * server/transport graph forever. We cap the live count, stamp a last-access
206
+ * time on every request, evict idle sessions on access and via a periodic
207
+ * reaper, and reject new sessions once at capacity. The limits are overridable
208
+ * via env so an operator can tune them without editing the generated source. */
209
+ interface Session {
210
+ server: McpServer;
211
+ transport: StreamableHTTPServerTransport;
212
+ lastAccess: number;
213
+ }
214
+ const sessions = new Map<string, Session>();
215
+
216
+ function envInt(name: string, fallback: number): number {
217
+ const n = parseInt(process.env[name] ?? '', 10);
218
+ return Number.isInteger(n) && n > 0 ? n : fallback;
219
+ }
220
+ const MAX_SESSIONS = envInt('MCP_MAX_SESSIONS', 1000);
221
+ const SESSION_IDLE_MS = envInt('MCP_SESSION_IDLE_MS', 5 * 60 * 1000);
222
+ const SESSION_REAP_MS = envInt('MCP_SESSION_REAP_MS', 60 * 1000);
223
+
224
+ /* Close + drop a single session (best-effort; never throws). */
225
+ function destroySession(sid: string, session: Session): void {
226
+ sessions.delete(sid);
227
+ void session.transport.close().catch(() => {});
228
+ void session.server.close().catch(() => {});
229
+ }
230
+
231
+ /* Evict every session whose last access is older than the idle window. */
232
+ function evictIdleSessions(now: number): void {
233
+ for (const [sid, session] of sessions) {
234
+ if (now - session.lastAccess > SESSION_IDLE_MS) {
235
+ log('info', 'Evicting idle session', { sid });
236
+ destroySession(sid, session);
237
+ }
238
+ }
239
+ }
240
+
241
+ /* Periodic reaper — sweeps idle sessions even when no traffic arrives.
242
+ * `unref()` so it never keeps the process alive on its own. */
243
+ const reaper = setInterval(() => evictIdleSessions(Date.now()), SESSION_REAP_MS);
244
+ reaper.unref();
179
245
 
180
246
  function isInitializeRequest(body: unknown): boolean {
181
247
  return (
@@ -200,26 +266,39 @@ if (transportMode === 'http') {
200
266
  );
201
267
  await runWithTrace(trace, async () => {
202
268
  if (stateful) {
269
+ const now = Date.now();
270
+ // Sweep idle sessions on every access so an idle map is reclaimed even
271
+ // between reaper ticks (and before a capacity check can spuriously fail).
272
+ evictIdleSessions(now);
203
273
  const sessionId = headerValue(req.headers['mcp-session-id']);
204
274
  const existing = sessionId ? sessions.get(sessionId) : undefined;
205
275
  if (existing) {
276
+ existing.lastAccess = now;
206
277
  await existing.transport.handleRequest(req, res, parsedBody);
207
278
  return;
208
279
  }
209
280
  if (!sessionId && isInitializeRequest(parsedBody)) {
281
+ // Reject new sessions once at capacity rather than growing unbounded.
282
+ if (sessions.size >= MAX_SESSIONS) {
283
+ log('error', 'Session capacity reached, rejecting initialize', {
284
+ maxSessions: MAX_SESSIONS,
285
+ });
286
+ rpcError(res, null, -32000, 'Server at session capacity — retry later');
287
+ return;
288
+ }
210
289
  // New session: create a dedicated server + transport and register it
211
290
  // under the session id the SDK generates during initialize.
212
291
  const server = createMcpServer(config);
213
292
  const transport = new StreamableHTTPServerTransport({
214
293
  sessionIdGenerator: () => crypto.randomUUID(),
215
294
  onsessioninitialized: (sid: string) => {
216
- sessions.set(sid, { server, transport });
295
+ sessions.set(sid, { server, transport, lastAccess: Date.now() });
217
296
  },
218
297
  });
219
298
  transport.onclose = () => {
220
299
  const sid = transport.sessionId;
221
300
  if (sid) sessions.delete(sid);
222
- void server.close();
301
+ void server.close().catch(() => {});
223
302
  };
224
303
  await server.connect(transport);
225
304
  await transport.handleRequest(req, res, parsedBody);
@@ -247,9 +326,10 @@ if (transportMode === 'http') {
247
326
 
248
327
  /* ── Authentication ─────────────────────────────────────────────
249
328
  * Every endpoint except /health, /ready{{#if hasOAuth}}, and the public
250
- * OAuth metadata{{/if}} requires a valid bearer token when MCP_AUTH_TOKEN
251
- * is set. This is the in-container line of defense; the hosting backend
252
- * also validates before proxying. */
329
+ * OAuth metadata{{/if}} requires a valid bearer token. With MCP_AUTH_TOKEN
330
+ * unset the server fails closed (401) unless MCP_ALLOW_UNAUTHENTICATED=true.
331
+ * This is the in-container line of defense; the hosting backend also
332
+ * validates before proxying. */
253
333
  const isPublicPath =
254
334
  url.pathname === '/health' ||
255
335
  url.pathname === '/ready'{{#if hasOAuth}} ||
@@ -298,7 +378,7 @@ if (transportMode === 'http') {
298
378
  clientSecret: config.oauth2ClientSecret,
299
379
  authorizationUrl,
300
380
  tokenUrl,
301
- scopes: [],
381
+ scopes: config.oauth2Scopes,
302
382
  redirectUri: config.oauth2RedirectUri ?? '',
303
383
  },
304
384
  issuer,
@@ -412,6 +492,7 @@ if (transportMode === 'http') {
412
492
  process.on('SIGTERM', () => {
413
493
  log('info', 'SIGTERM received, shutting down');
414
494
  isReady = false;
495
+ clearInterval(reaper);
415
496
 
416
497
  httpServer.close(async () => {
417
498
  try {
@@ -18,6 +18,15 @@ const VALID_STATUSES = new Set<string>([
18
18
  'working', 'input_required', 'completed', 'failed', 'cancelled',
19
19
  ]);
20
20
 
21
+ const TERMINAL_STATUSES = new Set<string>(['completed', 'failed', 'cancelled']);
22
+
23
+ /* Trust model: this server is sessionless and authenticates every caller with a
24
+ * single shared bearer token, so all authorized callers are one principal — there
25
+ * is no per-user task ownership. Cross-tenant ownership would require sessions and
26
+ * is out of scope here. Task IDs are unguessable UUIDv4, so with enumeration
27
+ * disabled a task ID acts as an effective capability; we therefore (a) gate
28
+ * list-all behind explicit opt-in and (b) refuse to mutate terminal tasks. */
29
+
21
30
  /* ── MCP Tasks extension (2026-07-28) — JSON-RPC over /mcp ────────────────
22
31
  * The 2026-07-28 RC moves Tasks to an extension driven by tasks/get,
23
32
  * tasks/update, and tasks/cancel; tasks/list is removed (it can't be scoped
@@ -64,6 +73,12 @@ export function handleTaskRpc(
64
73
  return task ? rpcResult(id, taskToWire(task)) : rpcError(id, -32001, `Task ${taskId} not found`);
65
74
  }
66
75
  case 'tasks/update': {
76
+ // Refuse to overwrite a finished task: its result/error/status is final.
77
+ const existing = getTask(taskId);
78
+ if (!existing) return rpcError(id, -32001, `Task ${taskId} not found`);
79
+ if (TERMINAL_STATUSES.has(existing.status)) {
80
+ return rpcError(id, -32002, 'Task is in a terminal state');
81
+ }
67
82
  const status =
68
83
  typeof p.status === 'string' && VALID_STATUSES.has(p.status)
69
84
  ? (p.status as TaskStatus)
@@ -76,6 +91,11 @@ export function handleTaskRpc(
76
91
  return task ? rpcResult(id, taskToWire(task)) : rpcError(id, -32001, `Task ${taskId} not found`);
77
92
  }
78
93
  case 'tasks/cancel': {
94
+ const existing = getTask(taskId);
95
+ if (!existing) return rpcError(id, -32001, `Task ${taskId} not found`);
96
+ if (TERMINAL_STATUSES.has(existing.status)) {
97
+ return rpcError(id, -32002, 'Task is in a terminal state');
98
+ }
79
99
  const task = cancelTask(taskId);
80
100
  return task ? rpcResult(id, taskToWire(task)) : rpcError(id, -32001, `Task ${taskId} not found`);
81
101
  }
@@ -98,8 +118,17 @@ export function handleTaskRoutes(
98
118
  const method = req.method ?? 'GET';
99
119
  const path = url.pathname;
100
120
 
101
- // GET /tasks — list tasks
121
+ // GET /tasks — list tasks. Enumerating every task leaks all in-flight work to
122
+ // any authorized caller and defeats the unguessable-ID capability model, so it
123
+ // is disabled unless an operator explicitly opts in.
102
124
  if (path === '/tasks' && method === 'GET') {
125
+ if (process.env.MCP_ENABLE_TASK_LIST !== 'true') {
126
+ sendJson(res, 403, {
127
+ error: 'Task listing is disabled. Set MCP_ENABLE_TASK_LIST=true to enable it.',
128
+ });
129
+ return true;
130
+ }
131
+
103
132
  const statusParam = url.searchParams.get('status') ?? undefined;
104
133
  const limitParam = url.searchParams.get('limit');
105
134
  const limit = limitParam ? parseInt(limitParam, 10) : 50;
@@ -171,6 +200,16 @@ export function handleTaskRoutes(
171
200
 
172
201
  // POST /tasks/:taskId/cancel — cancel a task
173
202
  if (subPath === '/cancel' && method === 'POST') {
203
+ const existing = getTask(taskId);
204
+ if (!existing) {
205
+ sendJson(res, 404, { error: `Task ${taskId} not found` });
206
+ return true;
207
+ }
208
+ // A finished task's outcome is final; don't let a late cancel overwrite it.
209
+ if (TERMINAL_STATUSES.has(existing.status)) {
210
+ sendJson(res, 409, { error: 'Task is in a terminal state' });
211
+ return true;
212
+ }
174
213
  const task = cancelTask(taskId);
175
214
  if (!task) {
176
215
  sendJson(res, 404, { error: `Task ${taskId} not found` });
@@ -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}}
@@ -30,6 +30,11 @@ const SUPPORTED_PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26'];
30
30
  const PROTOCOL_REVISION = '2026-07-28'; // server/discover preflight only (SEP-2575); NOT the negotiated initialize version
31
31
  const SERVER_INFO = { name: '{{serverName}}', version: '{{serverVersion}}' } as const;
32
32
  const MAX_BODY = 4 * 1024 * 1024; // 4 MB
33
+ // A 4 MB body can carry a huge JSON-RPC batch; dispatching every element at once
34
+ // (Promise.all) lets one request fan out into thousands of simultaneous upstream
35
+ // calls. Cap the batch length and process it through a bounded-concurrency pool.
36
+ const MAX_BATCH = 50;
37
+ const BATCH_CONCURRENCY = 5;
33
38
 
34
39
  const toolByName = new Map<string, ToolEntry>(tools.map((t) => [t.definition.name, t]));
35
40
 
@@ -85,8 +90,34 @@ async function safeEqual(a: string, b: string): Promise<boolean> {
85
90
  for (let i = 0; i < da.length; i++) diff |= da[i] ^ db[i];
86
91
  return diff === 0;
87
92
  }
88
- async function isAuthorized(request: Request, expected: string | undefined): Promise<boolean> {
89
- if (!expected) return true; // auth disabled when MCP_AUTH_TOKEN is unset (local/dev only)
93
+ // Fail CLOSED: when MCP_AUTH_TOKEN is unset every authenticated route is denied
94
+ // unless the operator opts in with MCP_ALLOW_UNAUTHENTICATED === 'true' (a
95
+ // local/dev escape hatch). We warn once per isolate so the open posture is loud.
96
+ let unauthWarned = false;
97
+ async function isAuthorized(request: Request, env: Env): Promise<boolean> {
98
+ const expected = env.MCP_AUTH_TOKEN;
99
+ if (!expected) {
100
+ if (env.MCP_ALLOW_UNAUTHENTICATED === 'true') {
101
+ if (!unauthWarned) {
102
+ unauthWarned = true;
103
+ console.warn(
104
+ 'SECURITY: MCP_AUTH_TOKEN is unset and MCP_ALLOW_UNAUTHENTICATED=true — ' +
105
+ 'this Worker is serving authenticated routes WITHOUT a bearer token. ' +
106
+ 'Set MCP_AUTH_TOKEN before exposing this endpoint.',
107
+ );
108
+ }
109
+ return true;
110
+ }
111
+ if (!unauthWarned) {
112
+ unauthWarned = true;
113
+ console.error(
114
+ 'SECURITY: MCP_AUTH_TOKEN is unset — denying all authenticated routes. ' +
115
+ 'Set MCP_AUTH_TOKEN (wrangler secret put MCP_AUTH_TOKEN), or set ' +
116
+ 'MCP_ALLOW_UNAUTHENTICATED=true for local/dev only.',
117
+ );
118
+ }
119
+ return false;
120
+ }
90
121
  const header = request.headers.get('authorization');
91
122
  if (!header || !header.startsWith('Bearer ')) return false;
92
123
  const presented = header.slice(7).trim();
@@ -94,19 +125,41 @@ async function isAuthorized(request: Request, expected: string | undefined): Pro
94
125
  return safeEqual(presented, expected);
95
126
  }
96
127
 
97
- /* ── HTTP / CORS helpers ─────────────────────────────────────────────── */
98
- function corsHeaders(origin?: string | null): Record<string, string> {
99
- return {
100
- 'access-control-allow-origin': origin || '*',
128
+ /* ── HTTP / CORS helpers ───────────────────────────────────────────────
129
+ * These responses are credentialed (gated by a bearer token), so we never
130
+ * emit a blanket `Access-Control-Allow-Origin: *`. CORS headers are reflected
131
+ * only when the request Origin is in the MCP_ALLOWED_ORIGINS allowlist
132
+ * (comma-separated); otherwise the response carries no ACAO header at all. */
133
+ function originAllowed(origin: string | null | undefined, env: Env): boolean {
134
+ if (!origin) return false;
135
+ const raw = env.MCP_ALLOWED_ORIGINS;
136
+ if (!raw) return false;
137
+ return raw
138
+ .split(',')
139
+ .map((s) => s.trim())
140
+ .filter(Boolean)
141
+ .includes(origin);
142
+ }
143
+ function corsHeaders(origin: string | null | undefined, env: Env): Record<string, string> {
144
+ const headers: Record<string, string> = {
101
145
  'access-control-allow-methods': 'POST, GET, OPTIONS',
102
146
  'access-control-allow-headers':
103
147
  'authorization, content-type, mcp-method, mcp-name, mcp-protocol-version, traceparent, tracestate',
104
148
  };
149
+ if (originAllowed(origin, env)) {
150
+ headers['access-control-allow-origin'] = origin as string;
151
+ headers['vary'] = 'Origin';
152
+ }
153
+ return headers;
105
154
  }
106
- function json(body: unknown, status = 200, extra?: Record<string, string>): Response {
155
+ function json(
156
+ body: unknown,
157
+ status = 200,
158
+ extra?: Record<string, string>,
159
+ ): Response {
107
160
  return new Response(JSON.stringify(body), {
108
161
  status,
109
- headers: { 'content-type': 'application/json', ...corsHeaders(), ...extra },
162
+ headers: { 'content-type': 'application/json', ...extra },
110
163
  });
111
164
  }
112
165
  function ok(id: unknown, result: unknown): object {
@@ -115,8 +168,13 @@ function ok(id: unknown, result: unknown): object {
115
168
  function err(id: unknown, code: number, message: string): object {
116
169
  return { jsonrpc: '2.0', id: id ?? null, error: { code, message } };
117
170
  }
118
- function rpcErrorResponse(id: unknown, code: number, message: string): Response {
119
- return json(err(id, code, message));
171
+ function rpcErrorResponse(
172
+ id: unknown,
173
+ code: number,
174
+ message: string,
175
+ cors?: Record<string, string>,
176
+ ): Response {
177
+ return json(err(id, code, message), 200, cors);
120
178
  }
121
179
 
122
180
  /* ── server/discover (SEP-2575) — VERIFY result shape against the final spec ── */
@@ -162,6 +220,26 @@ async function handleToolCall(
162
220
  return ok(id, result);
163
221
  }
164
222
 
223
+ /* Map `items` through `fn` with at most `limit` in flight at once, preserving
224
+ * input order in the result. Bounds the upstream fan-out of a JSON-RPC batch so
225
+ * one request cannot launch an unbounded burst of concurrent tool calls. */
226
+ async function mapWithConcurrency<T, R>(
227
+ items: T[],
228
+ limit: number,
229
+ fn: (item: T, index: number) => Promise<R>,
230
+ ): Promise<R[]> {
231
+ const results = new Array<R>(items.length);
232
+ let next = 0;
233
+ const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
234
+ while (next < items.length) {
235
+ const index = next++;
236
+ results[index] = await fn(items[index], index);
237
+ }
238
+ });
239
+ await Promise.all(workers);
240
+ return results;
241
+ }
242
+
165
243
  /** Dispatch one JSON-RPC message. Returns null for notifications (no reply). */
166
244
  async function handleRpc(
167
245
  msg: { id?: unknown; method?: unknown; params?: unknown } | null,
@@ -210,16 +288,19 @@ export default {
210
288
  async fetch(request: Request, env: Env): Promise<Response> {
211
289
  const url = new URL(request.url);
212
290
  const origin = request.headers.get('origin');
291
+ // Computed once: empty unless the request Origin is in MCP_ALLOWED_ORIGINS.
292
+ const cors = corsHeaders(origin, env);
213
293
 
214
294
  if (request.method === 'OPTIONS') {
215
- return new Response(null, { status: 204, headers: corsHeaders(origin) });
295
+ return new Response(null, { status: 204, headers: cors });
216
296
  }
217
297
  if (url.pathname === '/health') return json({ status: 'ok' });
218
298
  if (url.pathname === '/ready') return json({ status: 'ready' });
219
299
 
220
300
  // Bearer authentication (every path except /health and /ready).
221
- if (!(await isAuthorized(request, env.MCP_AUTH_TOKEN))) {
301
+ if (!(await isAuthorized(request, env))) {
222
302
  return json({ error: 'Unauthorized: missing or invalid bearer token' }, 401, {
303
+ ...cors,
223
304
  'www-authenticate': 'Bearer',
224
305
  });
225
306
  }
@@ -231,30 +312,37 @@ export default {
231
312
  console.log(JSON.stringify({ level: 'info', msg: 'mcp-meta-headers', mcpMethod, mcpName }));
232
313
  }
233
314
 
234
- if (url.pathname !== '/mcp') return json({ error: 'Not found' }, 404);
315
+ if (url.pathname !== '/mcp') return json({ error: 'Not found' }, 404, cors);
235
316
  if (request.method !== 'POST') {
236
317
  // Stateless: no SSE stream to open (GET) or session to delete (DELETE).
237
- return rpcErrorResponse(null, -32601, 'Only POST is supported on this stateless server');
318
+ return rpcErrorResponse(
319
+ null,
320
+ -32601,
321
+ 'Only POST is supported on this stateless server',
322
+ cors,
323
+ );
238
324
  }
239
325
 
240
326
  // Origin guard (DNS-rebinding protection). Enforced only when configured —
241
327
  // a Workers endpoint is public and gated by the bearer token, not by origin.
242
328
  const allowedOrigin = env.ALLOWED_ORIGIN;
243
329
  if (allowedOrigin && origin && origin !== allowedOrigin) {
244
- return json({ error: 'Forbidden: invalid origin' }, 403);
330
+ return json({ error: 'Forbidden: invalid origin' }, 403, cors);
245
331
  }
246
332
 
247
333
  const contentLength = Number(request.headers.get('content-length') ?? '0');
248
- if (contentLength > MAX_BODY) return rpcErrorResponse(null, -32600, 'Request body too large');
334
+ if (contentLength > MAX_BODY)
335
+ return rpcErrorResponse(null, -32600, 'Request body too large', cors);
249
336
  const raw = await request.text();
250
- if (raw.length > MAX_BODY) return rpcErrorResponse(null, -32600, 'Request body too large');
251
- if (!raw) return rpcErrorResponse(null, -32600, 'Empty request body');
337
+ if (raw.length > MAX_BODY)
338
+ return rpcErrorResponse(null, -32600, 'Request body too large', cors);
339
+ if (!raw) return rpcErrorResponse(null, -32600, 'Empty request body', cors);
252
340
 
253
341
  let parsed: unknown;
254
342
  try {
255
343
  parsed = JSON.parse(raw);
256
344
  } catch {
257
- return rpcErrorResponse(null, -32700, 'Parse error');
345
+ return rpcErrorResponse(null, -32700, 'Parse error', cors);
258
346
  }
259
347
 
260
348
  const config = loadConfig(env);
@@ -267,20 +355,26 @@ export default {
267
355
 
268
356
  return runWithTrace(trace, async () => {
269
357
  if (Array.isArray(parsed)) {
270
- const settled = await Promise.all(
271
- parsed.map((m) => handleRpc(m as { id?: unknown; method?: unknown }, config, env)),
358
+ if (parsed.length === 0) {
359
+ return rpcErrorResponse(null, -32600, 'Empty JSON-RPC batch', cors);
360
+ }
361
+ if (parsed.length > MAX_BATCH) {
362
+ return rpcErrorResponse(null, -32600, `Batch too large (max ${MAX_BATCH})`, cors);
363
+ }
364
+ const settled = await mapWithConcurrency(parsed, BATCH_CONCURRENCY, (m) =>
365
+ handleRpc(m as { id?: unknown; method?: unknown }, config, env),
272
366
  );
273
367
  const responses = settled.filter((r): r is object => r !== null);
274
368
  if (responses.length === 0) {
275
- return new Response(null, { status: 202, headers: corsHeaders(origin) });
369
+ return new Response(null, { status: 202, headers: cors });
276
370
  }
277
- return json(responses);
371
+ return json(responses, 200, cors);
278
372
  }
279
373
  const response = await handleRpc(parsed as { id?: unknown; method?: unknown }, config, env);
280
374
  if (response === null) {
281
- return new Response(null, { status: 202, headers: corsHeaders(origin) });
375
+ return new Response(null, { status: 202, headers: cors });
282
376
  }
283
- return json(response);
377
+ return json(response, 200, cors);
284
378
  });
285
379
  },
286
380
  };