@mcpmake/core 0.3.3 → 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.
- package/dist/analyzer/goal-crawler.d.ts.map +1 -1
- package/dist/analyzer/goal-crawler.js +4 -11
- package/dist/analyzer/goal-crawler.js.map +1 -1
- package/dist/analyzer/same-origin.d.ts +21 -0
- package/dist/analyzer/same-origin.d.ts.map +1 -0
- package/dist/analyzer/same-origin.js +32 -0
- package/dist/analyzer/same-origin.js.map +1 -0
- package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
- package/dist/analyzer/semantic-analyzer.js +3 -0
- package/dist/analyzer/semantic-analyzer.js.map +1 -1
- package/dist/analyzer/site-crawler.d.ts.map +1 -1
- package/dist/analyzer/site-crawler.js +3 -10
- package/dist/analyzer/site-crawler.js.map +1 -1
- package/dist/emitter/index.d.ts.map +1 -1
- package/dist/emitter/index.js +21 -1
- package/dist/emitter/index.js.map +1 -1
- package/dist/emitter/project-scaffolder.d.ts +5 -0
- package/dist/emitter/project-scaffolder.d.ts.map +1 -1
- package/dist/emitter/project-scaffolder.js +1 -1
- package/dist/emitter/project-scaffolder.js.map +1 -1
- package/dist/emitter/python-template-loader.js +1 -1
- package/dist/emitter/python-template-loader.js.map +1 -1
- package/dist/emitter/python-templates/server.py.hbs +40 -25
- package/dist/emitter/site-templates/browser-manager.ts.hbs +5 -2
- package/dist/emitter/site-templates/config.ts.hbs +9 -4
- package/dist/emitter/site-templates/server-main-http.ts.hbs +13 -4
- package/dist/emitter/site-templates/server-main.ts.hbs +5 -2
- package/dist/emitter/templates/config.ts.hbs +29 -2
- package/dist/emitter/templates/discovery.ts.hbs +26 -12
- package/dist/emitter/templates/http-executor.ts.hbs +4 -3
- package/dist/emitter/templates/oauth.ts.hbs +1 -2
- package/dist/emitter/templates/readme.md.hbs +3 -2
- package/dist/emitter/templates/resources.ts.hbs +3 -2
- package/dist/emitter/templates/server-main-http.ts.hbs +140 -130
- package/dist/emitter/templates/task-handlers.ts.hbs +2 -1
- package/dist/emitter/templates/task-manager.ts.hbs +5 -3
- package/dist/emitter/templates/task-sse.ts.hbs +9 -1
- package/dist/emitter/templates/tool-handler.ts.hbs +17 -4
- package/dist/emitter/worker-templates/config.ts.hbs +25 -2
- package/dist/emitter/worker-templates/worker.ts.hbs +6 -2
- package/dist/parser/har-normalizer.js +1 -1
- package/dist/parser/har-normalizer.js.map +1 -1
- package/dist/parser/openapi-loader.d.ts +19 -0
- package/dist/parser/openapi-loader.d.ts.map +1 -1
- package/dist/parser/openapi-loader.js +78 -10
- package/dist/parser/openapi-loader.js.map +1 -1
- package/dist/parser/operation-extractor.js +4 -4
- package/dist/parser/operation-extractor.js.map +1 -1
- package/dist/parser/overlay-loader.js +50 -11
- package/dist/parser/overlay-loader.js.map +1 -1
- package/dist/parser/postman-loader.d.ts.map +1 -1
- package/dist/parser/postman-loader.js +41 -5
- package/dist/parser/postman-loader.js.map +1 -1
- package/dist/parser/schema-converter.d.ts +7 -0
- package/dist/parser/schema-converter.d.ts.map +1 -1
- package/dist/parser/schema-converter.js +11 -1
- package/dist/parser/schema-converter.js.map +1 -1
- package/dist/rescan/diff-engine.js +17 -10
- package/dist/rescan/diff-engine.js.map +1 -1
- package/dist/rescan/rescan-runner.js +4 -4
- package/dist/rescan/rescan-runner.js.map +1 -1
- package/dist/rescan/rescan-scheduler.d.ts +1 -0
- package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
- package/dist/rescan/rescan-scheduler.js +30 -17
- package/dist/rescan/rescan-scheduler.js.map +1 -1
- package/dist/site-transformer/selector-healer.d.ts.map +1 -1
- package/dist/site-transformer/selector-healer.js +9 -0
- package/dist/site-transformer/selector-healer.js.map +1 -1
- package/dist/site-transformer/tool-generator.js +4 -7
- package/dist/site-transformer/tool-generator.js.map +1 -1
- package/dist/transformer/auth-detector.d.ts.map +1 -1
- package/dist/transformer/auth-detector.js +49 -1
- package/dist/transformer/auth-detector.js.map +1 -1
- package/dist/transformer/catalog-builder.d.ts +18 -2
- package/dist/transformer/catalog-builder.d.ts.map +1 -1
- package/dist/transformer/catalog-builder.js +31 -12
- package/dist/transformer/catalog-builder.js.map +1 -1
- package/dist/transformer/client-compat.d.ts.map +1 -1
- package/dist/transformer/client-compat.js +17 -6
- package/dist/transformer/client-compat.js.map +1 -1
- package/dist/transformer/har-dedup.js +2 -2
- package/dist/transformer/har-dedup.js.map +1 -1
- package/dist/transformer/har-to-operations.d.ts.map +1 -1
- package/dist/transformer/har-to-operations.js +10 -3
- package/dist/transformer/har-to-operations.js.map +1 -1
- package/dist/transformer/llm-namer.d.ts.map +1 -1
- package/dist/transformer/llm-namer.js +3 -0
- package/dist/transformer/llm-namer.js.map +1 -1
- package/dist/transformer/resource-builder.d.ts.map +1 -1
- package/dist/transformer/resource-builder.js +112 -18
- package/dist/transformer/resource-builder.js.map +1 -1
- package/dist/transformer/stainless-translator.d.ts.map +1 -1
- package/dist/transformer/stainless-translator.js +5 -2
- package/dist/transformer/stainless-translator.js.map +1 -1
- package/dist/transformer/tool-builder.d.ts.map +1 -1
- package/dist/transformer/tool-builder.js +64 -16
- package/dist/transformer/tool-builder.js.map +1 -1
- 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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
128
|
-
requestIntervalMs:
|
|
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
|
-
|
|
23
|
-
|
|
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
|
|
163
|
-
if (args[
|
|
164
|
-
url = url.replace(`{${
|
|
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
|
|
171
|
-
if (args[
|
|
172
|
-
queryParams.set(
|
|
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
|
|
182
|
-
? { body: args
|
|
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
|
|
198
|
-
const
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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:
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
457
|
+
await dispatchToTransport(req, res, parsed);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
459
460
|
|
|
460
|
-
|
|
461
|
-
|
|
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: '
|
|
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(
|
|
470
|
-
res.end(
|
|
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
|
-
|
|
493
|
-
log('info',
|
|
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
|
|
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
|
|
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
|
|
41
|
-
|
|
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',
|