@mcpmake/core 0.3.2 → 0.3.4

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 (98) hide show
  1. package/dist/analyzer/goal-crawler.d.ts.map +1 -1
  2. package/dist/analyzer/goal-crawler.js +4 -11
  3. package/dist/analyzer/goal-crawler.js.map +1 -1
  4. package/dist/analyzer/same-origin.d.ts +21 -0
  5. package/dist/analyzer/same-origin.d.ts.map +1 -0
  6. package/dist/analyzer/same-origin.js +32 -0
  7. package/dist/analyzer/same-origin.js.map +1 -0
  8. package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
  9. package/dist/analyzer/semantic-analyzer.js +3 -0
  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 +3 -10
  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 +21 -1
  16. package/dist/emitter/index.js.map +1 -1
  17. package/dist/emitter/project-scaffolder.d.ts +5 -0
  18. package/dist/emitter/project-scaffolder.d.ts.map +1 -1
  19. package/dist/emitter/project-scaffolder.js +1 -1
  20. package/dist/emitter/project-scaffolder.js.map +1 -1
  21. package/dist/emitter/python-template-loader.js +1 -1
  22. package/dist/emitter/python-template-loader.js.map +1 -1
  23. package/dist/emitter/python-templates/server.py.hbs +40 -25
  24. package/dist/emitter/site-templates/browser-manager.ts.hbs +5 -2
  25. package/dist/emitter/site-templates/config.ts.hbs +9 -4
  26. package/dist/emitter/site-templates/server-main-http.ts.hbs +13 -4
  27. package/dist/emitter/site-templates/server-main.ts.hbs +5 -2
  28. package/dist/emitter/templates/config.ts.hbs +29 -2
  29. package/dist/emitter/templates/discovery.ts.hbs +26 -12
  30. package/dist/emitter/templates/http-executor.ts.hbs +4 -3
  31. package/dist/emitter/templates/oauth.ts.hbs +1 -2
  32. package/dist/emitter/templates/readme.md.hbs +3 -2
  33. package/dist/emitter/templates/resources.ts.hbs +3 -2
  34. package/dist/emitter/templates/server-main-http.ts.hbs +140 -130
  35. package/dist/emitter/templates/task-handlers.ts.hbs +2 -1
  36. package/dist/emitter/templates/task-manager.ts.hbs +5 -3
  37. package/dist/emitter/templates/task-sse.ts.hbs +9 -1
  38. package/dist/emitter/templates/tool-handler.ts.hbs +17 -4
  39. package/dist/emitter/worker-templates/config.ts.hbs +25 -2
  40. package/dist/emitter/worker-templates/worker.ts.hbs +6 -2
  41. package/dist/parser/har-normalizer.js +1 -1
  42. package/dist/parser/har-normalizer.js.map +1 -1
  43. package/dist/parser/openapi-loader.d.ts +19 -0
  44. package/dist/parser/openapi-loader.d.ts.map +1 -1
  45. package/dist/parser/openapi-loader.js +78 -10
  46. package/dist/parser/openapi-loader.js.map +1 -1
  47. package/dist/parser/operation-extractor.js +4 -4
  48. package/dist/parser/operation-extractor.js.map +1 -1
  49. package/dist/parser/overlay-loader.js +50 -11
  50. package/dist/parser/overlay-loader.js.map +1 -1
  51. package/dist/parser/postman-loader.d.ts.map +1 -1
  52. package/dist/parser/postman-loader.js +41 -5
  53. package/dist/parser/postman-loader.js.map +1 -1
  54. package/dist/parser/schema-converter.d.ts +7 -0
  55. package/dist/parser/schema-converter.d.ts.map +1 -1
  56. package/dist/parser/schema-converter.js +11 -1
  57. package/dist/parser/schema-converter.js.map +1 -1
  58. package/dist/rescan/diff-engine.js +17 -10
  59. package/dist/rescan/diff-engine.js.map +1 -1
  60. package/dist/rescan/rescan-runner.js +4 -4
  61. package/dist/rescan/rescan-runner.js.map +1 -1
  62. package/dist/rescan/rescan-scheduler.d.ts +1 -0
  63. package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
  64. package/dist/rescan/rescan-scheduler.js +30 -17
  65. package/dist/rescan/rescan-scheduler.js.map +1 -1
  66. package/dist/site-transformer/selector-healer.d.ts.map +1 -1
  67. package/dist/site-transformer/selector-healer.js +9 -0
  68. package/dist/site-transformer/selector-healer.js.map +1 -1
  69. package/dist/site-transformer/tool-generator.js +4 -7
  70. package/dist/site-transformer/tool-generator.js.map +1 -1
  71. package/dist/transformer/auth-detector.d.ts.map +1 -1
  72. package/dist/transformer/auth-detector.js +49 -1
  73. package/dist/transformer/auth-detector.js.map +1 -1
  74. package/dist/transformer/catalog-builder.d.ts +18 -2
  75. package/dist/transformer/catalog-builder.d.ts.map +1 -1
  76. package/dist/transformer/catalog-builder.js +31 -12
  77. package/dist/transformer/catalog-builder.js.map +1 -1
  78. package/dist/transformer/client-compat.d.ts.map +1 -1
  79. package/dist/transformer/client-compat.js +17 -6
  80. package/dist/transformer/client-compat.js.map +1 -1
  81. package/dist/transformer/har-dedup.js +2 -2
  82. package/dist/transformer/har-dedup.js.map +1 -1
  83. package/dist/transformer/har-to-operations.d.ts.map +1 -1
  84. package/dist/transformer/har-to-operations.js +10 -3
  85. package/dist/transformer/har-to-operations.js.map +1 -1
  86. package/dist/transformer/llm-namer.d.ts.map +1 -1
  87. package/dist/transformer/llm-namer.js +3 -0
  88. package/dist/transformer/llm-namer.js.map +1 -1
  89. package/dist/transformer/resource-builder.d.ts.map +1 -1
  90. package/dist/transformer/resource-builder.js +112 -18
  91. package/dist/transformer/resource-builder.js.map +1 -1
  92. package/dist/transformer/stainless-translator.d.ts.map +1 -1
  93. package/dist/transformer/stainless-translator.js +5 -2
  94. package/dist/transformer/stainless-translator.js.map +1 -1
  95. package/dist/transformer/tool-builder.d.ts.map +1 -1
  96. package/dist/transformer/tool-builder.js +64 -16
  97. package/dist/transformer/tool-builder.js.map +1 -1
  98. package/package.json +1 -1
@@ -74,6 +74,10 @@ let isReady = false;
74
74
 
75
75
  if (process.env.TRANSPORT === 'http') {
76
76
  const port = parseInt(process.env.PORT ?? '3000', 10);
77
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
78
+ log('error', 'Invalid PORT value, must be 1-65535', { PORT: process.env.PORT });
79
+ process.exit(1);
80
+ }
77
81
  const allowedOrigin = process.env.ALLOWED_ORIGIN;
78
82
 
79
83
  const httpServer = http.createServer(async (req, res) => {
@@ -159,8 +163,10 @@ if (process.env.TRANSPORT === 'http') {
159
163
  const shutdown = async () => {
160
164
  log('info', 'Shutting down...');
161
165
  await closeBrowser();
162
- httpServer.close();
163
- setTimeout(() => process.exit(0), 5000);
166
+ // Exit as soon as the server finishes draining; the timer is only a
167
+ // forced-exit backstop and is unref'd so it never holds the loop open.
168
+ httpServer.close(() => process.exit(0));
169
+ setTimeout(() => process.exit(0), 5000).unref();
164
170
  };
165
171
 
166
172
  process.on('SIGTERM', shutdown);
@@ -172,8 +178,11 @@ if (process.env.TRANSPORT === 'http') {
172
178
  await server.connect(transport);
173
179
  isReady = true;
174
180
 
175
- process.on('SIGTERM', async () => {
181
+ const shutdown = async () => {
176
182
  await closeBrowser();
177
183
  process.exit(0);
178
- });
184
+ };
185
+
186
+ process.on('SIGTERM', shutdown);
187
+ process.on('SIGINT', shutdown);
179
188
  }
@@ -17,7 +17,10 @@ registerAllTools(server, config);
17
17
  const transport = new StdioServerTransport();
18
18
  await server.connect(transport);
19
19
 
20
- process.on('SIGTERM', async () => {
20
+ const shutdown = async () => {
21
21
  await closeBrowser();
22
22
  process.exit(0);
23
- });
23
+ };
24
+
25
+ process.on('SIGTERM', shutdown);
26
+ process.on('SIGINT', shutdown);
@@ -2,20 +2,29 @@ export interface AppConfig {
2
2
  baseUrl: string;
3
3
  {{#each authSchemes}}
4
4
  {{#if (eq type "apiKey")}}
5
+ {{#unless (eq emitApiKeyValue false)}}
5
6
  apiKey?: string;
7
+ {{/unless}}
6
8
  {{#if (eq in "query")}}
9
+ {{#unless (eq emitApiKeyQueryName false)}}
7
10
  /** Query-parameter name the API key is appended under (apiKey-in-query auth). */
8
11
  apiKeyQueryName?: string;
12
+ {{/unless}}
9
13
  {{/if}}
10
14
  {{/if}}
11
15
  {{#if (eq type "http-bearer")}}
16
+ {{#unless (eq emitBearer false)}}
12
17
  bearerToken?: string;
18
+ {{/unless}}
13
19
  {{/if}}
14
20
  {{#if (eq type "http-basic")}}
21
+ {{#unless (eq emitBasic false)}}
15
22
  basicUsername?: string;
16
23
  basicPassword?: string;
24
+ {{/unless}}
17
25
  {{/if}}
18
26
  {{#if (eq type "oauth2")}}
27
+ {{#unless (eq emitOAuth2 false)}}
19
28
  oauth2Token?: string;
20
29
  oauth2ClientId?: string;
21
30
  oauth2ClientSecret?: string;
@@ -25,6 +34,7 @@ export interface AppConfig {
25
34
  /** OAuth scopes requested on token grants. Defaults to the spec-declared
26
35
  * scopes; override at runtime with OAUTH2_SCOPES (space-separated). */
27
36
  oauth2Scopes: string[];
37
+ {{/unless}}
28
38
  {{/if}}
29
39
  {{/each}}
30
40
  maxRetries: number;
@@ -81,6 +91,7 @@ function loadDefaultHeaders(): Record<string, string> {
81
91
 
82
92
  {{#each authSchemes}}
83
93
  {{#if (eq type "oauth2")}}
94
+ {{#unless (eq emitOAuth2 false)}}
84
95
  /**
85
96
  * Resolve the OAuth scopes requested on token grants. Defaults to the
86
97
  * spec-declared scopes baked in at generation time; `OAUTH2_SCOPES`
@@ -95,16 +106,32 @@ function resolveOAuthScopes(): string[] {
95
106
  return {{#if scopes}}{{{json scopes}}}{{else}}[]{{/if}};
96
107
  }
97
108
 
109
+ {{/unless}}
98
110
  {{/if}}
99
111
  {{/each}}
112
+ const posInt = (v: string | undefined, d: number): number => {
113
+ const n = parseInt(v ?? '', 10);
114
+ return Number.isInteger(n) && n > 0 ? n : d;
115
+ };
116
+
117
+ /** Like posInt but accepts 0 — useful where 0 is a meaningful "disable" value. */
118
+ const nonNegInt = (v: string | undefined, d: number): number => {
119
+ const n = parseInt(v ?? '', 10);
120
+ return Number.isInteger(n) && n >= 0 ? n : d;
121
+ };
122
+
100
123
  export function loadConfig(): AppConfig {
101
124
  return {
102
125
  baseUrl: resolveBaseUrl(),
103
126
  {{#each authSchemes}}
104
127
  {{#if (eq type "apiKey")}}
128
+ {{#unless (eq emitApiKeyValue false)}}
105
129
  apiKey: process.env.{{envVarName}},
130
+ {{/unless}}
106
131
  {{#if (eq in "query")}}
132
+ {{#unless (eq emitApiKeyQueryName false)}}
107
133
  apiKeyQueryName: {{{json headerName}}},
134
+ {{/unless}}
108
135
  {{/if}}
109
136
  {{/if}}
110
137
  {{#if (eq type "http-bearer")}}
@@ -124,8 +151,8 @@ export function loadConfig(): AppConfig {
124
151
  oauth2Scopes: resolveOAuthScopes(),
125
152
  {{/if}}
126
153
  {{/each}}
127
- maxRetries: parseInt(process.env.MAX_RETRIES ?? '3', 10),
128
- requestIntervalMs: parseInt(process.env.REQUEST_INTERVAL_MS ?? '100', 10),
154
+ maxRetries: nonNegInt(process.env.MAX_RETRIES, 3),
155
+ requestIntervalMs: nonNegInt(process.env.REQUEST_INTERVAL_MS, 100),
129
156
  defaultHeaders: loadDefaultHeaders(),
130
157
  };
131
158
  }
@@ -12,6 +12,13 @@ import { applyJqFilter } from './response-filter.js';
12
12
  import type { AppConfig } from './config.js';
13
13
  import catalogData from './tool-catalog.json' with { type: 'json' };
14
14
 
15
+ interface CatalogParamMapping {
16
+ /** The key the agent supplies in the tool input. */
17
+ inputKey: string;
18
+ /** The original API parameter name sent upstream. */
19
+ wireName: string;
20
+ }
21
+
15
22
  interface CatalogEntry {
16
23
  name: string;
17
24
  title: string;
@@ -19,10 +26,15 @@ interface CatalogEntry {
19
26
  method: string;
20
27
  path: string;
21
28
  inputSchema: Record<string, unknown>;
22
- pathParams: string[];
23
- queryParams: string[];
29
+ /** Path parameters with inputKey (agent-facing) and wireName (upstream API). */
30
+ pathParams: CatalogParamMapping[];
31
+ /** Query parameters with inputKey (agent-facing) and wireName (upstream API). */
32
+ queryParams: CatalogParamMapping[];
24
33
  hasRequestBody: boolean;
25
34
  requestBodyContentType: string;
35
+ /** The MCP input key for the request body (`body`, `requestBody`, etc.).
36
+ * Only present when `hasRequestBody` is true. */
37
+ bodyInputKey?: string;
26
38
  }
27
39
 
28
40
  const catalog: CatalogEntry[] = catalogData as CatalogEntry[];
@@ -157,29 +169,31 @@ export function registerDiscoveryTools(server: McpServer, config: AppConfig): vo
157
169
  }
158
170
 
159
171
  try {
160
- // Build URL from path template
172
+ // Build URL from path template — read args by inputKey, substitute wireName
173
+ // into the URL template. Handles parameter name collisions (R14-A).
161
174
  let url = config.baseUrl + tool.path;
162
- for (const param of tool.pathParams) {
163
- if (args[param] !== undefined) {
164
- url = url.replace(`{${param}}`, encodeURIComponent(String(args[param])));
175
+ for (const p of tool.pathParams) {
176
+ if (args[p.inputKey] !== undefined) {
177
+ url = url.replace(`{${p.wireName}}`, encodeURIComponent(String(args[p.inputKey])));
165
178
  }
166
179
  }
167
180
 
168
- // Add query params
181
+ // Add query params — read args by inputKey, send upstream under wireName (R14-A).
169
182
  const queryParams = new URLSearchParams();
170
- for (const param of tool.queryParams) {
171
- if (args[param] !== undefined) {
172
- queryParams.set(param, String(args[param]));
183
+ for (const p of tool.queryParams) {
184
+ if (args[p.inputKey] !== undefined) {
185
+ queryParams.set(p.wireName, String(args[p.inputKey]));
173
186
  }
174
187
  }
175
188
  const qs = queryParams.toString();
176
189
  if (qs) url += `?${qs}`;
177
190
 
191
+ const bodyKey = tool.bodyInputKey ?? 'body';
178
192
  const response = await executeRequest({
179
193
  method: tool.method,
180
194
  url,
181
- ...(tool.hasRequestBody && args.body
182
- ? { body: args.body, contentType: tool.requestBodyContentType }
195
+ ...(tool.hasRequestBody && args[bodyKey] !== undefined
196
+ ? { body: args[bodyKey], contentType: tool.requestBodyContentType }
183
197
  : {}),
184
198
  headers: {},
185
199
  config,
@@ -194,9 +194,10 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
194
194
  });
195
195
 
196
196
  if (RETRYABLE_STATUS_CODES.includes(response.status) && attempt < config.maxRetries) {
197
- const retryAfter = response.headers.get('Retry-After');
198
- const delay = retryAfter
199
- ? parseInt(retryAfter, 10) * 1000
197
+ const retryAfterRaw = response.headers.get('Retry-After');
198
+ const retryAfterMs = retryAfterRaw ? parseInt(retryAfterRaw, 10) * 1000 : NaN;
199
+ const delay = Number.isFinite(retryAfterMs)
200
+ ? Math.min(retryAfterMs, 60_000)
200
201
  : 1000 * Math.pow(2, attempt);
201
202
  await sleep(delay);
202
203
  continue;
@@ -86,8 +86,7 @@ async function clientCredentialsGrant(config: OAuthConfig): Promise<TokenRespons
86
86
  });
87
87
 
88
88
  if (!response.ok) {
89
- const body = await response.text();
90
- throw new Error(`OAuth client credentials failed (${response.status}): ${body}`);
89
+ throw new Error(`OAuth client credentials failed (${response.status})`);
91
90
  }
92
91
 
93
92
  return response.json() as Promise<TokenResponse>;
@@ -75,14 +75,15 @@ Copy `.env.example` to `.env` and fill in:
75
75
  {{#each authEnvVars}}
76
76
  | `{{name}}` | {{description}} | {{#if required}}Yes{{else}}No{{/if}} |
77
77
  {{/each}}
78
- | `MAX_RETRIES` | Max retry attempts (default: 3) | No |
79
- | `REQUEST_INTERVAL_MS` | Min ms between requests (default: 100) | No |
78
+ | `MAX_RETRIES` | Max retry attempts (default: 3; `0` disables retries) | No |
79
+ | `REQUEST_INTERVAL_MS` | Min ms between requests (default: 100; `0` disables throttling) | No |
80
80
  | `MCP_ENVIRONMENTS` | JSON map of environment name → base URL (e.g. `{"production":"…","sandbox":"…"}`) | No |
81
81
  | `API_ENVIRONMENT` | Selects a named environment from `MCP_ENVIRONMENTS` (falls back to `BASE_URL`) | No |
82
82
  | `MCP_TOOLS` | Comma-separated allowlist of tool names to register | No |
83
83
  | `MCP_EXCLUDE_TOOLS` | Comma-separated denylist of tool names to skip | No |
84
84
  | `MCP_DEFAULT_HEADERS` | JSON object of headers added to every upstream request | No |
85
85
  | `MCP_IDEMPOTENCY_AUTO` | `true` to auto-generate an `Idempotency-Key` for mutating requests | No |
86
+ | `MCP_MAX_SSE_CONNECTIONS` | Max concurrent task-streaming SSE connections (default: 500; HTTP transport only) | No |
86
87
 
87
88
  ### Named environments
88
89
 
@@ -1,4 +1,5 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { executeRequest } from './http.js';
3
4
  import type { AppConfig } from './config.js';
4
5
 
@@ -7,12 +8,12 @@ export function registerAllResources(server: McpServer, config: AppConfig): void
7
8
  {{#if isTemplate}}
8
9
  server.resource(
9
10
  '{{name}}',
10
- '{{uri}}',
11
+ new ResourceTemplate('{{uri}}', { list: undefined }),
11
12
  {
12
13
  description: `{{{description}}}`,
13
14
  mimeType: 'application/json',
14
15
  },
15
- async (uri) => {
16
+ async (uri, variables) => {
16
17
  {{{urlBody}}}
17
18
  const response = await executeRequest({
18
19
  method: 'get',
@@ -321,157 +321,164 @@ if (transportMode === 'http') {
321
321
  }
322
322
 
323
323
  const httpServer = http.createServer(async (req, res) => {
324
- const url = new URL(req.url ?? '/', `http://localhost:${port}`);
325
- const method = req.method ?? 'GET';
326
-
327
- /* ── Authentication ─────────────────────────────────────────────
328
- * Every endpoint except /health, /ready{{#if hasOAuth}}, and the public
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. */
333
- const isPublicPath =
334
- url.pathname === '/health' ||
335
- url.pathname === '/ready'{{#if hasOAuth}} ||
336
- url.pathname === '/.well-known/oauth-authorization-server'{{/if}};
337
- if (!isPublicPath && !isAuthorized(req, authToken)) {
338
- log('error', 'Unauthorized request rejected', { path: url.pathname });
339
- res.writeHead(401, {
340
- 'Content-Type': 'application/json',
341
- 'WWW-Authenticate': 'Bearer',
342
- });
343
- res.end(JSON.stringify({ error: 'Unauthorized: missing or invalid bearer token' }));
344
- return;
345
- }
324
+ try {
325
+ const url = new URL(req.url ?? '/', `http://localhost:${port}`);
326
+ const method = req.method ?? 'GET';
327
+
328
+ /* ── Authentication ─────────────────────────────────────────────
329
+ * Every endpoint except /health, /ready{{#if hasOAuth}}, and the public
330
+ * OAuth metadata{{/if}} requires a valid bearer token. With MCP_AUTH_TOKEN
331
+ * unset the server fails closed (401) unless MCP_ALLOW_UNAUTHENTICATED=true.
332
+ * This is the in-container line of defense; the hosting backend also
333
+ * validates before proxying. */
334
+ const isPublicPath =
335
+ url.pathname === '/health' ||
336
+ url.pathname === '/ready'{{#if hasOAuth}} ||
337
+ url.pathname === '/.well-known/oauth-authorization-server'{{/if}};
338
+ if (!isPublicPath && !isAuthorized(req, authToken)) {
339
+ log('error', 'Unauthorized request rejected', { path: url.pathname });
340
+ res.writeHead(401, {
341
+ 'Content-Type': 'application/json',
342
+ 'WWW-Authenticate': 'Bearer',
343
+ });
344
+ res.end(JSON.stringify({ error: 'Unauthorized: missing or invalid bearer token' }));
345
+ return;
346
+ }
346
347
 
347
- /* ── Routing-header observability (SEP-2243, additive) ─────────── */
348
- const mcpMethod = req.headers['mcp-method'];
349
- const mcpName = req.headers['mcp-name'];
350
- if (mcpMethod || mcpName) {
351
- log('info', 'mcp-meta-headers', { mcpMethod, mcpName, path: url.pathname });
352
- }
348
+ /* ── Routing-header observability (SEP-2243, additive) ─────────── */
349
+ const mcpMethod = req.headers['mcp-method'];
350
+ const mcpName = req.headers['mcp-name'];
351
+ if (mcpMethod || mcpName) {
352
+ log('info', 'mcp-meta-headers', { mcpMethod, mcpName, path: url.pathname });
353
+ }
353
354
 
354
355
  {{#if hasOAuth}}
355
- /* ── OAuth Authorization Server Metadata (RFC 8414) ─────────────
356
- * Public discovery endpoint (the 2026-07-28 auth changes clarify
357
- * .well-known discovery; DCR is retained, not replaced). */
358
- if (url.pathname === '/.well-known/oauth-authorization-server') {
359
- if (method !== 'GET') {
360
- res.writeHead(405, { Allow: 'GET' });
361
- res.end();
362
- return;
363
- }
364
- const authorizationUrl = config.oauth2AuthorizationUrl;
365
- const tokenUrl = config.oauth2TokenUrl;
366
- if (!authorizationUrl || !tokenUrl) {
367
- sendJson(res, 503, { error: 'OAuth is not configured' });
356
+ /* ── OAuth Authorization Server Metadata (RFC 8414) ─────────────
357
+ * Public discovery endpoint (the 2026-07-28 auth changes clarify
358
+ * .well-known discovery; DCR is retained, not replaced). */
359
+ if (url.pathname === '/.well-known/oauth-authorization-server') {
360
+ if (method !== 'GET') {
361
+ res.writeHead(405, { Allow: 'GET' });
362
+ res.end();
363
+ return;
364
+ }
365
+ const authorizationUrl = config.oauth2AuthorizationUrl;
366
+ const tokenUrl = config.oauth2TokenUrl;
367
+ if (!authorizationUrl || !tokenUrl) {
368
+ sendJson(res, 503, { error: 'OAuth is not configured' });
369
+ return;
370
+ }
371
+ const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
372
+ const issuer = allowedOrigin ?? `${proto}://${req.headers.host ?? 'localhost'}`;
373
+ sendJson(
374
+ res,
375
+ 200,
376
+ getAuthServerMetadata(
377
+ {
378
+ clientId: config.oauth2ClientId ?? '',
379
+ clientSecret: config.oauth2ClientSecret,
380
+ authorizationUrl,
381
+ tokenUrl,
382
+ scopes: config.oauth2Scopes,
383
+ redirectUri: config.oauth2RedirectUri ?? '',
384
+ },
385
+ issuer,
386
+ ),
387
+ );
368
388
  return;
369
389
  }
370
- const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http';
371
- const issuer = allowedOrigin ?? `${proto}://${req.headers.host ?? 'localhost'}`;
372
- sendJson(
373
- res,
374
- 200,
375
- getAuthServerMetadata(
376
- {
377
- clientId: config.oauth2ClientId ?? '',
378
- clientSecret: config.oauth2ClientSecret,
379
- authorizationUrl,
380
- tokenUrl,
381
- scopes: config.oauth2Scopes,
382
- redirectUri: config.oauth2RedirectUri ?? '',
383
- },
384
- issuer,
385
- ),
386
- );
387
- return;
388
- }
389
390
 
390
391
  {{/if}}
391
392
  {{#if hasAsyncTools}}
392
- /* ── Task management REST routes (retained during the grace period) ── */
393
- if (handleTaskSse(req, res, url)) return;
394
- if (handleTaskRoutes(req, res, url)) return;
393
+ /* ── Task management REST routes (retained during the grace period) ── */
394
+ if (handleTaskSse(req, res, url)) return;
395
+ if (handleTaskRoutes(req, res, url)) return;
395
396
 
396
397
  {{/if}}
397
- if (url.pathname === '/mcp') {
398
- /* Origin validation (DNS-rebinding protection per MCP spec) */
399
- const origin = req.headers.origin;
400
- if (allowedOrigin) {
401
- if (origin && origin !== allowedOrigin) {
402
- log('error', 'Origin rejected', { origin, allowedOrigin });
398
+ if (url.pathname === '/mcp') {
399
+ /* Origin validation (DNS-rebinding protection per MCP spec) */
400
+ const origin = req.headers.origin;
401
+ if (allowedOrigin) {
402
+ if (origin && origin !== allowedOrigin) {
403
+ log('error', 'Origin rejected', { origin, allowedOrigin });
404
+ res.writeHead(403, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
406
+ return;
407
+ }
408
+ } else if (origin) {
409
+ /* No allowlist configured but Origin header present — reject cross-origin requests */
410
+ log('error', 'Cross-origin request rejected (ALLOWED_ORIGIN not set)', { origin });
403
411
  res.writeHead(403, { 'Content-Type': 'application/json' });
404
- res.end(JSON.stringify({ error: 'Forbidden: invalid origin' }));
412
+ res.end(JSON.stringify({ error: 'Forbidden: ALLOWED_ORIGIN not configured' }));
405
413
  return;
406
414
  }
407
- } else if (origin) {
408
- /* No allowlist configured but Origin header present — reject cross-origin requests */
409
- log('error', 'Cross-origin request rejected (ALLOWED_ORIGIN not set)', { origin });
410
- res.writeHead(403, { 'Content-Type': 'application/json' });
411
- res.end(JSON.stringify({ error: 'Forbidden: ALLOWED_ORIGIN not configured' }));
412
- return;
413
- }
414
415
 
415
- /* POST carries a JSON-RPC body: peek for server/discover + tasks/* and
416
- * forward the parsed body to the transport. GET/DELETE (SSE stream /
417
- * session teardown) pass straight through. */
418
- if (method === 'POST') {
419
- const raw = await readBody(req);
420
- if (raw === null) {
421
- rpcError(res, null, -32600, 'Request body too large');
422
- return;
423
- }
424
- if (raw.length === 0) {
425
- rpcError(res, null, -32600, 'Empty request body');
426
- return;
427
- }
428
- let parsed: unknown;
429
- try {
430
- parsed = JSON.parse(raw.toString('utf-8'));
431
- } catch {
432
- rpcError(res, null, -32700, 'Parse error');
433
- return;
434
- }
435
-
436
- /* Intercept single-object JSON-RPC extension methods the SDK doesn't
437
- * dispatch yet. Batches are forwarded untouched. */
438
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
439
- const rpcMethod = (parsed as { method?: unknown }).method;
440
- const rpcId = (parsed as { id?: unknown }).id;
441
- if (rpcMethod === 'server/discover') {
442
- sendJson(res, 200, buildDiscoverResult(rpcId));
416
+ /* POST carries a JSON-RPC body: peek for server/discover + tasks/* and
417
+ * forward the parsed body to the transport. GET/DELETE (SSE stream /
418
+ * session teardown) pass straight through. */
419
+ if (method === 'POST') {
420
+ const raw = await readBody(req);
421
+ if (raw === null) {
422
+ rpcError(res, null, -32600, 'Request body too large');
443
423
  return;
444
424
  }
445
- {{#if hasAsyncTools}}
446
- if (typeof rpcMethod === 'string' && rpcMethod.startsWith('tasks/')) {
447
- const handled = handleTaskRpc(rpcMethod, (parsed as { params?: unknown }).params, rpcId);
448
- if (handled) {
449
- sendJson(res, 200, handled);
425
+ if (raw.length === 0) {
426
+ rpcError(res, null, -32600, 'Empty request body');
427
+ return;
428
+ }
429
+ let parsed: unknown;
430
+ try {
431
+ parsed = JSON.parse(raw.toString('utf-8'));
432
+ } catch {
433
+ rpcError(res, null, -32700, 'Parse error');
434
+ return;
435
+ }
436
+
437
+ /* Intercept single-object JSON-RPC extension methods the SDK doesn't
438
+ * dispatch yet. Batches are forwarded untouched. */
439
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
440
+ const rpcMethod = (parsed as { method?: unknown }).method;
441
+ const rpcId = (parsed as { id?: unknown }).id;
442
+ if (rpcMethod === 'server/discover') {
443
+ sendJson(res, 200, buildDiscoverResult(rpcId));
450
444
  return;
451
445
  }
452
- }
446
+ {{#if hasAsyncTools}}
447
+ if (typeof rpcMethod === 'string' && rpcMethod.startsWith('tasks/')) {
448
+ const handled = handleTaskRpc(rpcMethod, (parsed as { params?: unknown }).params, rpcId);
449
+ if (handled) {
450
+ sendJson(res, 200, handled);
451
+ return;
452
+ }
453
+ }
453
454
  {{/if}}
454
- }
455
+ }
455
456
 
456
- await dispatchToTransport(req, res, parsed);
457
- return;
458
- }
457
+ await dispatchToTransport(req, res, parsed);
458
+ return;
459
+ }
459
460
 
460
- await dispatchToTransport(req, res);
461
- } else if (url.pathname === '/health') {
462
- res.writeHead(200, { 'Content-Type': 'application/json' });
463
- res.end(JSON.stringify({ status: 'ok' }));
464
- } else if (url.pathname === '/ready') {
465
- if (isReady) {
461
+ await dispatchToTransport(req, res);
462
+ } else if (url.pathname === '/health') {
466
463
  res.writeHead(200, { 'Content-Type': 'application/json' });
467
- res.end(JSON.stringify({ status: 'ready' }));
464
+ res.end(JSON.stringify({ status: 'ok' }));
465
+ } else if (url.pathname === '/ready') {
466
+ if (isReady) {
467
+ res.writeHead(200, { 'Content-Type': 'application/json' });
468
+ res.end(JSON.stringify({ status: 'ready' }));
469
+ } else {
470
+ res.writeHead(503, { 'Content-Type': 'application/json' });
471
+ res.end(JSON.stringify({ status: 'not ready' }));
472
+ }
468
473
  } else {
469
- res.writeHead(503, { 'Content-Type': 'application/json' });
470
- res.end(JSON.stringify({ status: 'not ready' }));
474
+ res.writeHead(404);
475
+ res.end('Not found');
476
+ }
477
+ } catch (e) {
478
+ log('error', 'Unhandled error in request handler', { err: String(e) });
479
+ if (!res.headersSent) {
480
+ sendJson(res, 500, { error: 'Internal server error' });
471
481
  }
472
- } else {
473
- res.writeHead(404);
474
- res.end('Not found');
475
482
  }
476
483
  });
477
484
 
@@ -489,8 +496,8 @@ if (transportMode === 'http') {
489
496
 
490
497
  /* ── Graceful shutdown ─────────────────────────────────────────────── */
491
498
 
492
- process.on('SIGTERM', () => {
493
- log('info', 'SIGTERM received, shutting down');
499
+ const shutdown = (signal: string) => {
500
+ log('info', `${signal} received, shutting down`);
494
501
  isReady = false;
495
502
  clearInterval(reaper);
496
503
 
@@ -514,7 +521,10 @@ if (transportMode === 'http') {
514
521
  log('error', 'Shutdown timed out after 5 s, forcing exit');
515
522
  process.exit(1);
516
523
  }, 5000).unref();
517
- });
524
+ };
525
+
526
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
527
+ process.on('SIGINT', () => shutdown('SIGINT'));
518
528
  } else {
519
529
  const server = createMcpServer(config);
520
530
  const transport = new StdioServerTransport();
@@ -131,7 +131,8 @@ export function handleTaskRoutes(
131
131
 
132
132
  const statusParam = url.searchParams.get('status') ?? undefined;
133
133
  const limitParam = url.searchParams.get('limit');
134
- const limit = limitParam ? parseInt(limitParam, 10) : 50;
134
+ const rawLimit = parseInt(limitParam ?? '', 10);
135
+ const limit = Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= 1000 ? rawLimit : 50;
135
136
 
136
137
  if (statusParam && !VALID_STATUSES.has(statusParam)) {
137
138
  sendJson(res, 400, { error: `Invalid status filter: ${statusParam}` });
@@ -35,10 +35,12 @@ const MAX_TASKS = 1000;
35
35
  export const taskEvents = new EventEmitter();
36
36
 
37
37
  export function createTask(toolName: string): Task {
38
- // Evict oldest if at capacity
38
+ // Evict a terminal task first; only fall back to the oldest (by insertion order) if none exist.
39
39
  if (tasks.size >= MAX_TASKS) {
40
- const oldest = tasks.keys().next().value;
41
- if (oldest) tasks.delete(oldest);
40
+ const terminalKey = [...tasks.entries()].find(
41
+ ([, t]) => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled',
42
+ )?.[0] ?? tasks.keys().next().value;
43
+ if (terminalKey !== undefined) tasks.delete(terminalKey);
42
44
  }
43
45
 
44
46
  const task: Task = {
@@ -37,7 +37,7 @@ function stopHeartbeatIfEmpty(): void {
37
37
  function broadcast(eventName: string, data: unknown, taskId: string): void {
38
38
  const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
39
39
  for (const conn of connections) {
40
- if (conn.res.writableEnded) continue;
40
+ if (conn.res.writableEnded) { connections.delete(conn); continue; }
41
41
  if (conn.taskIdFilter && conn.taskIdFilter !== taskId) continue;
42
42
  conn.res.write(payload);
43
43
  }
@@ -83,6 +83,14 @@ export function handleTaskSse(
83
83
 
84
84
  const taskIdFilter = url.searchParams.get('taskId') ?? null;
85
85
 
86
+ const rawMax = parseInt(process.env.MCP_MAX_SSE_CONNECTIONS ?? '', 10);
87
+ const maxConnections = Number.isInteger(rawMax) && rawMax > 0 ? rawMax : 500;
88
+ if (connections.size >= maxConnections) {
89
+ res.writeHead(503, { 'Content-Type': 'application/json' });
90
+ res.end(JSON.stringify({ error: 'SSE connection limit reached' }));
91
+ return true;
92
+ }
93
+
86
94
  res.writeHead(200, {
87
95
  'Content-Type': 'text/event-stream',
88
96
  'Cache-Control': 'no-cache',