@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.
- package/dist/analyzer/dom-parser.d.ts +35 -0
- package/dist/analyzer/dom-parser.d.ts.map +1 -1
- package/dist/analyzer/dom-parser.js +107 -6
- package/dist/analyzer/dom-parser.js.map +1 -1
- package/dist/analyzer/goal-crawler.d.ts +2 -1
- package/dist/analyzer/goal-crawler.d.ts.map +1 -1
- package/dist/analyzer/goal-crawler.js +66 -17
- package/dist/analyzer/goal-crawler.js.map +1 -1
- package/dist/analyzer/semantic-analyzer.d.ts +1 -1
- package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
- package/dist/analyzer/semantic-analyzer.js +15 -16
- 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 +122 -16
- package/dist/analyzer/site-crawler.js.map +1 -1
- package/dist/config/mcpmake-config.d.ts.map +1 -1
- package/dist/config/mcpmake-config.js +5 -0
- package/dist/config/mcpmake-config.js.map +1 -1
- package/dist/emitter/code-writer.d.ts +1 -0
- package/dist/emitter/code-writer.d.ts.map +1 -1
- package/dist/emitter/code-writer.js +79 -12
- package/dist/emitter/code-writer.js.map +1 -1
- package/dist/emitter/index.d.ts +8 -0
- package/dist/emitter/index.d.ts.map +1 -1
- package/dist/emitter/index.js +113 -6
- package/dist/emitter/index.js.map +1 -1
- package/dist/emitter/project-scaffolder.d.ts +13 -0
- package/dist/emitter/project-scaffolder.d.ts.map +1 -1
- package/dist/emitter/project-scaffolder.js +29 -0
- package/dist/emitter/project-scaffolder.js.map +1 -1
- package/dist/emitter/python-template-loader.d.ts.map +1 -1
- package/dist/emitter/python-template-loader.js +10 -0
- package/dist/emitter/python-template-loader.js.map +1 -1
- package/dist/emitter/python-templates/dockerfile.hbs +4 -1
- package/dist/emitter/python-templates/server.py.hbs +82 -8
- package/dist/emitter/site-scaffolder.d.ts.map +1 -1
- package/dist/emitter/site-scaffolder.js +28 -1
- package/dist/emitter/site-scaffolder.js.map +1 -1
- package/dist/emitter/site-templates/browser-manager.ts.hbs +104 -34
- package/dist/emitter/site-templates/dockerfile.hbs +13 -4
- package/dist/emitter/site-templates/env.example.hbs +8 -0
- package/dist/emitter/site-templates/server-main-http.ts.hbs +77 -6
- package/dist/emitter/site-templates/telemetry.ts.hbs +117 -0
- package/dist/emitter/site-templates/tool-handler-form.ts.hbs +10 -8
- package/dist/emitter/template-loader.d.ts.map +1 -1
- package/dist/emitter/template-loader.js +9 -0
- package/dist/emitter/template-loader.js.map +1 -1
- package/dist/emitter/templates/auth-provider.ts.hbs +86 -21
- package/dist/emitter/templates/config.ts.hbs +29 -0
- package/dist/emitter/templates/dockerfile.hbs +12 -4
- package/dist/emitter/templates/env.example.hbs +6 -0
- package/dist/emitter/templates/http-executor.ts.hbs +150 -7
- package/dist/emitter/templates/oauth.ts.hbs +19 -65
- package/dist/emitter/templates/server-main-http.ts.hbs +93 -12
- package/dist/emitter/templates/task-handlers.ts.hbs +40 -1
- package/dist/emitter/templates/tool-handler.ts.hbs +37 -2
- package/dist/emitter/templates/tool-test.ts.hbs +9 -1
- package/dist/emitter/worker-templates/config.ts.hbs +7 -0
- package/dist/emitter/worker-templates/tool-handler.ts.hbs +37 -2
- package/dist/emitter/worker-templates/worker.ts.hbs +119 -25
- package/dist/generator/spec-generator.d.ts.map +1 -1
- package/dist/generator/spec-generator.js +9 -54
- package/dist/generator/spec-generator.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/llm/anthropic-provider.d.ts +14 -0
- package/dist/llm/anthropic-provider.d.ts.map +1 -0
- package/dist/llm/anthropic-provider.js +50 -0
- package/dist/llm/anthropic-provider.js.map +1 -0
- package/dist/llm/index.d.ts +19 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +73 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/openai-provider.d.ts +46 -0
- package/dist/llm/openai-provider.d.ts.map +1 -0
- package/dist/llm/openai-provider.js +221 -0
- package/dist/llm/openai-provider.js.map +1 -0
- package/dist/llm/types.d.ts +61 -0
- package/dist/llm/types.d.ts.map +1 -0
- package/dist/llm/types.js +16 -0
- package/dist/llm/types.js.map +1 -0
- package/dist/parser/har-filter.d.ts +7 -0
- package/dist/parser/har-filter.d.ts.map +1 -1
- package/dist/parser/har-filter.js +107 -1
- package/dist/parser/har-filter.js.map +1 -1
- package/dist/parser/index.d.ts +1 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +1 -1
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/openapi-loader.d.ts.map +1 -1
- package/dist/parser/openapi-loader.js +36 -2
- package/dist/parser/openapi-loader.js.map +1 -1
- package/dist/parser/operation-extractor.d.ts.map +1 -1
- package/dist/parser/operation-extractor.js +12 -1
- package/dist/parser/operation-extractor.js.map +1 -1
- package/dist/parser/overlay-loader.d.ts.map +1 -1
- package/dist/parser/overlay-loader.js +11 -2
- package/dist/parser/overlay-loader.js.map +1 -1
- package/dist/parser/postman-loader.d.ts.map +1 -1
- package/dist/parser/postman-loader.js +6 -1
- package/dist/parser/postman-loader.js.map +1 -1
- package/dist/parser/schema-converter.d.ts +13 -2
- package/dist/parser/schema-converter.d.ts.map +1 -1
- package/dist/parser/schema-converter.js +64 -42
- package/dist/parser/schema-converter.js.map +1 -1
- package/dist/plugins/loader.d.ts +5 -1
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +39 -10
- package/dist/plugins/loader.js.map +1 -1
- package/dist/pricing.d.ts +5 -1
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +7 -3
- package/dist/pricing.js.map +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +6 -21
- package/dist/providers/index.js.map +1 -1
- package/dist/recorder/browser-recorder.d.ts.map +1 -1
- package/dist/recorder/browser-recorder.js +89 -12
- package/dist/recorder/browser-recorder.js.map +1 -1
- package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
- package/dist/rescan/rescan-scheduler.js +22 -7
- package/dist/rescan/rescan-scheduler.js.map +1 -1
- package/dist/site-transformer/selector-healer.d.ts +1 -1
- package/dist/site-transformer/selector-healer.d.ts.map +1 -1
- package/dist/site-transformer/selector-healer.js +17 -18
- package/dist/site-transformer/selector-healer.js.map +1 -1
- package/dist/site-transformer/tool-generator.d.ts.map +1 -1
- package/dist/site-transformer/tool-generator.js +90 -22
- package/dist/site-transformer/tool-generator.js.map +1 -1
- package/dist/transformer/auth-detector.d.ts.map +1 -1
- package/dist/transformer/auth-detector.js +24 -5
- package/dist/transformer/auth-detector.js.map +1 -1
- package/dist/transformer/har-schema-inferrer.d.ts.map +1 -1
- package/dist/transformer/har-schema-inferrer.js +2 -0
- package/dist/transformer/har-schema-inferrer.js.map +1 -1
- package/dist/transformer/har-to-operations.d.ts +1 -0
- package/dist/transformer/har-to-operations.d.ts.map +1 -1
- package/dist/transformer/har-to-operations.js +42 -10
- 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 +10 -13
- package/dist/transformer/llm-namer.js.map +1 -1
- package/dist/transformer/naming.d.ts.map +1 -1
- package/dist/transformer/naming.js +16 -8
- package/dist/transformer/naming.js.map +1 -1
- package/dist/transformer/resource-builder.d.ts.map +1 -1
- package/dist/transformer/resource-builder.js +13 -6
- package/dist/transformer/resource-builder.js.map +1 -1
- package/dist/transformer/stainless-translator.d.ts +6 -0
- package/dist/transformer/stainless-translator.d.ts.map +1 -1
- package/dist/transformer/stainless-translator.js +18 -1
- package/dist/transformer/stainless-translator.js.map +1 -1
- package/dist/transformer/tool-builder.d.ts.map +1 -1
- package/dist/transformer/tool-builder.js +141 -14
- package/dist/transformer/tool-builder.js.map +1 -1
- package/dist/types/index.d.ts +79 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/site.d.ts +19 -1
- package/dist/types/site.d.ts.map +1 -1
- package/dist/utils/fail.d.ts.map +1 -1
- package/dist/utils/fail.js +22 -4
- package/dist/utils/fail.js.map +1 -1
- package/dist/utils/json-extract.d.ts +21 -0
- package/dist/utils/json-extract.d.ts.map +1 -0
- package/dist/utils/json-extract.js +67 -0
- package/dist/utils/json-extract.js.map +1 -0
- package/dist/utils/model-resolver.d.ts.map +1 -1
- package/dist/utils/model-resolver.js +6 -0
- package/dist/utils/model-resolver.js.map +1 -1
- package/dist/utils/sanitize.d.ts +63 -1
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +147 -2
- package/dist/utils/sanitize.js.map +1 -1
- package/dist/utils/ssrf-guard.d.ts +10 -0
- package/dist/utils/ssrf-guard.d.ts.map +1 -0
- package/dist/utils/ssrf-guard.js +130 -0
- package/dist/utils/ssrf-guard.js.map +1 -0
- package/dist/utils/watcher.d.ts +17 -1
- package/dist/utils/watcher.d.ts.map +1 -1
- package/dist/utils/watcher.js +53 -21
- package/dist/utils/watcher.js.map +1 -1
- 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.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
251
|
-
*
|
|
252
|
-
*
|
|
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.
|
|
55
|
+
body: input.{{bodyInputKey}},
|
|
46
56
|
contentType: '{{requestBodyContentType}}',
|
|
57
|
+
bodyEncoding: '{{bodyEncoding}}',
|
|
47
58
|
{{/if}}
|
|
48
|
-
headers:
|
|
59
|
+
headers: buildHeaders(input),
|
|
49
60
|
config,
|
|
50
61
|
idempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined,
|
|
62
|
+
{{#if authRequirement}}
|
|
63
|
+
// Per-operation outbound-auth requirement derived from OpenAPI `security` (D-H2).
|
|
64
|
+
authRequirement: {{{json authRequirement}}},
|
|
65
|
+
{{/if}}
|
|
51
66
|
});
|
|
52
67
|
|
|
53
68
|
let result: unknown = response.data;
|
|
@@ -140,3 +155,23 @@ function buildUrl(
|
|
|
140
155
|
): string {
|
|
141
156
|
{{{buildUrlBody}}}
|
|
142
157
|
}
|
|
158
|
+
|
|
159
|
+
// Build the upstream header/cookie set from the tool input. Header and cookie
|
|
160
|
+
// parameters are sent under their original API names (see paramMappings).
|
|
161
|
+
function buildHeaders(params: Record<string, unknown>): Record<string, string> {
|
|
162
|
+
{{{buildHeadersBody}}}
|
|
163
|
+
}
|
|
164
|
+
{{#if hasQueryApiKey}}
|
|
165
|
+
|
|
166
|
+
// Append extra query parameters (apiKey-in-query auth) onto an already-built URL,
|
|
167
|
+
// preserving any existing query string and URL-encoding name and value.
|
|
168
|
+
function appendQueryParams(url: string, extra: Record<string, string>): string {
|
|
169
|
+
const keys = Object.keys(extra);
|
|
170
|
+
if (keys.length === 0) return url;
|
|
171
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
172
|
+
const qs = keys
|
|
173
|
+
.map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(extra[k]))
|
|
174
|
+
.join('&');
|
|
175
|
+
return url + sep + qs;
|
|
176
|
+
}
|
|
177
|
+
{{/if}}
|
|
@@ -25,7 +25,15 @@ function capture(): { name: string; def: Record<string, unknown>; handler: unkno
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
describe('{{name}}', () => {
|
|
28
|
-
//
|
|
28
|
+
// Source operation metadata. Serialized via JSON.stringify on the builder side
|
|
29
|
+
// so an untrusted OpenAPI path (which can contain newlines/quotes) can never
|
|
30
|
+
// escape a comment or string literal into module-level code.
|
|
31
|
+
const operation = {{{operationMeta}}};
|
|
32
|
+
|
|
33
|
+
it('targets the source operation', () => {
|
|
34
|
+
expect(operation.method).toBe('{{method}}');
|
|
35
|
+
expect(typeof operation.path).toBe('string');
|
|
36
|
+
});
|
|
29
37
|
|
|
30
38
|
it('registers exactly one tool under its name', () => {
|
|
31
39
|
const calls = capture();
|
|
@@ -8,6 +8,10 @@ export interface AppConfig {
|
|
|
8
8
|
{{#each authSchemes}}
|
|
9
9
|
{{#if (eq type "apiKey")}}
|
|
10
10
|
apiKey?: string;
|
|
11
|
+
{{#if (eq in "query")}}
|
|
12
|
+
/** Query-parameter name the API key is appended under (apiKey-in-query auth). */
|
|
13
|
+
apiKeyQueryName?: string;
|
|
14
|
+
{{/if}}
|
|
11
15
|
{{/if}}
|
|
12
16
|
{{#if (eq type "http-bearer")}}
|
|
13
17
|
bearerToken?: string;
|
|
@@ -69,6 +73,9 @@ export function loadConfig(env: EnvLike): AppConfig {
|
|
|
69
73
|
{{#each authSchemes}}
|
|
70
74
|
{{#if (eq type "apiKey")}}
|
|
71
75
|
apiKey: env.{{envVarName}},
|
|
76
|
+
{{#if (eq in "query")}}
|
|
77
|
+
apiKeyQueryName: {{{json headerName}}},
|
|
78
|
+
{{/if}}
|
|
72
79
|
{{/if}}
|
|
73
80
|
{{#if (eq type "http-bearer")}}
|
|
74
81
|
bearerToken: env.{{envVarName}},
|
|
@@ -2,6 +2,9 @@ import { z } from 'zod';
|
|
|
2
2
|
import { executeRequest } from '../http.js';
|
|
3
3
|
import { applyJqFilter } from '../response-filter.js';
|
|
4
4
|
import type { AppConfig } from '../config.js';
|
|
5
|
+
{{#if hasQueryApiKey}}
|
|
6
|
+
import { getAuthQueryParams } from '../auth.js';
|
|
7
|
+
{{/if}}
|
|
5
8
|
|
|
6
9
|
// Per-call control arguments shared by every generated tool. Optional and
|
|
7
10
|
// stripped before the upstream request, so omitting them is a no-op.
|
|
@@ -70,18 +73,30 @@ export async function handler(
|
|
|
70
73
|
async function runRequest(rawInput: Record<string, unknown>, config: AppConfig): Promise<unknown> {
|
|
71
74
|
// Pull off the per-call control args so they are never forwarded upstream.
|
|
72
75
|
const { jq_filter: jqFilter, idempotency_key: idempotencyKey, ...input } = rawInput;
|
|
76
|
+
{{#if hasQueryApiKey}}
|
|
77
|
+
let url = buildUrl(input, config.baseUrl);
|
|
78
|
+
// apiKey-in-query auth: append the key as a query parameter (it cannot be sent
|
|
79
|
+
// as a header). Respects the per-operation security requirement (D-H2).
|
|
80
|
+
url = appendQueryParams(url, getAuthQueryParams(config, {{#if authRequirement}}{{{json authRequirement}}}{{else}}undefined{{/if}}));
|
|
81
|
+
{{else}}
|
|
73
82
|
const url = buildUrl(input, config.baseUrl);
|
|
83
|
+
{{/if}}
|
|
74
84
|
|
|
75
85
|
const response = await executeRequest({
|
|
76
86
|
method: '{{method}}',
|
|
77
87
|
url,
|
|
78
88
|
{{#if hasRequestBody}}
|
|
79
|
-
body: input.
|
|
89
|
+
body: input.{{bodyInputKey}},
|
|
80
90
|
contentType: '{{requestBodyContentType}}',
|
|
91
|
+
bodyEncoding: '{{bodyEncoding}}',
|
|
81
92
|
{{/if}}
|
|
82
|
-
headers:
|
|
93
|
+
headers: buildHeaders(input),
|
|
83
94
|
config,
|
|
84
95
|
idempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined,
|
|
96
|
+
{{#if authRequirement}}
|
|
97
|
+
// Per-operation outbound-auth requirement derived from OpenAPI `security` (D-H2).
|
|
98
|
+
authRequirement: {{{json authRequirement}}},
|
|
99
|
+
{{/if}}
|
|
85
100
|
});
|
|
86
101
|
|
|
87
102
|
let result: unknown = response.data;
|
|
@@ -101,3 +116,23 @@ async function runRequest(rawInput: Record<string, unknown>, config: AppConfig):
|
|
|
101
116
|
function buildUrl(params: Record<string, unknown>, baseUrl: string): string {
|
|
102
117
|
{{{buildUrlBody}}}
|
|
103
118
|
}
|
|
119
|
+
|
|
120
|
+
// Build the upstream header/cookie set from the tool input. Header and cookie
|
|
121
|
+
// parameters are sent under their original API names (see paramMappings).
|
|
122
|
+
function buildHeaders(params: Record<string, unknown>): Record<string, string> {
|
|
123
|
+
{{{buildHeadersBody}}}
|
|
124
|
+
}
|
|
125
|
+
{{#if hasQueryApiKey}}
|
|
126
|
+
|
|
127
|
+
// Append extra query parameters (apiKey-in-query auth) onto an already-built URL,
|
|
128
|
+
// preserving any existing query string and URL-encoding name and value.
|
|
129
|
+
function appendQueryParams(url: string, extra: Record<string, string>): string {
|
|
130
|
+
const keys = Object.keys(extra);
|
|
131
|
+
if (keys.length === 0) return url;
|
|
132
|
+
const sep = url.includes('?') ? '&' : '?';
|
|
133
|
+
const qs = keys
|
|
134
|
+
.map((k) => encodeURIComponent(k) + '=' + encodeURIComponent(extra[k]))
|
|
135
|
+
.join('&');
|
|
136
|
+
return url + sep + qs;
|
|
137
|
+
}
|
|
138
|
+
{{/if}}
|
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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(
|
|
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', ...
|
|
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(
|
|
119
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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)
|
|
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)
|
|
251
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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:
|
|
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:
|
|
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
|
};
|