@mcpmake/core 0.2.5 → 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 +28 -0
- package/dist/analyzer/dom-parser.d.ts.map +1 -1
- package/dist/analyzer/dom-parser.js +77 -2
- 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 +86 -12
- 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.map +1 -1
- package/dist/emitter/python-templates/dockerfile.hbs +4 -1
- package/dist/emitter/python-templates/server.py.hbs +4 -0
- 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 +24 -3
- 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 +53 -0
- package/dist/emitter/site-templates/telemetry.ts.hbs +117 -0
- package/dist/emitter/templates/auth-provider.ts.hbs +9 -5
- package/dist/emitter/templates/config.ts.hbs +22 -0
- package/dist/emitter/templates/dockerfile.hbs +12 -4
- package/dist/emitter/templates/oauth.ts.hbs +19 -65
- package/dist/emitter/templates/server-main-http.ts.hbs +33 -8
- package/dist/emitter/templates/task-handlers.ts.hbs +40 -1
- package/dist/emitter/worker-templates/worker.ts.hbs +88 -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/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.map +1 -1
- package/dist/parser/schema-converter.js +34 -23
- 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/recorder/browser-recorder.d.ts.map +1 -1
- package/dist/recorder/browser-recorder.js +50 -5
- 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/transformer/auth-detector.d.ts.map +1 -1
- package/dist/transformer/auth-detector.js +13 -1
- 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/tool-builder.d.ts.map +1 -1
- package/dist/transformer/tool-builder.js +30 -1
- package/dist/transformer/tool-builder.js.map +1 -1
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.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 +1 -1
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +6 -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
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { chromium } from 'playwright';
|
|
7
7
|
import type { Browser, BrowserContext, Page } from 'playwright';
|
|
8
8
|
import type { BrowserConfig } from './config.js';
|
|
9
|
+
import * as telemetry from './telemetry.js';
|
|
9
10
|
|
|
10
11
|
interface BrowserSession {
|
|
11
12
|
context: BrowserContext;
|
|
@@ -177,6 +178,10 @@ async function createSession(id: string): Promise<{ page: Page; sessionId: strin
|
|
|
177
178
|
|
|
178
179
|
resetIdleTimer();
|
|
179
180
|
|
|
181
|
+
// The browser context is now resident — start billing this session (no-op
|
|
182
|
+
// unless the host injected telemetry env).
|
|
183
|
+
telemetry.sessionStarted(id);
|
|
184
|
+
|
|
180
185
|
return { page, sessionId: id };
|
|
181
186
|
} catch (err) {
|
|
182
187
|
// Release the reserved slot if context creation failed.
|
|
@@ -195,6 +200,7 @@ export async function closeSession(sessionId?: string): Promise<void> {
|
|
|
195
200
|
|
|
196
201
|
await session.context?.close().catch(() => {});
|
|
197
202
|
sessions.delete(id);
|
|
203
|
+
telemetry.sessionEnded(id, 'explicit');
|
|
198
204
|
|
|
199
205
|
// If no sessions remain, close the browser
|
|
200
206
|
if (sessions.size === 0) {
|
|
@@ -211,6 +217,7 @@ export async function closeBrowser(): Promise<void> {
|
|
|
211
217
|
for (const [id, session] of sessions) {
|
|
212
218
|
await session.context?.close().catch(() => {});
|
|
213
219
|
sessions.delete(id);
|
|
220
|
+
telemetry.sessionEnded(id, 'shutdown');
|
|
214
221
|
}
|
|
215
222
|
|
|
216
223
|
if (browser) {
|
|
@@ -261,6 +268,7 @@ function resetIdleTimer(): void {
|
|
|
261
268
|
if (now - session.lastAccessedAt > config.idleTimeoutMs) {
|
|
262
269
|
session.context.close().catch(() => {});
|
|
263
270
|
sessions.delete(id);
|
|
271
|
+
telemetry.sessionEnded(id, 'idle');
|
|
264
272
|
}
|
|
265
273
|
}
|
|
266
274
|
// Close browser if no sessions
|
|
@@ -277,6 +285,19 @@ function clearIdleTimer(): void {
|
|
|
277
285
|
}
|
|
278
286
|
}
|
|
279
287
|
|
|
280
|
-
// Clean up on process exit
|
|
281
|
-
|
|
282
|
-
|
|
288
|
+
// Clean up on process exit. Close the browser (which emits a session_end for
|
|
289
|
+
// every still-resident session), then flush telemetry best-effort so the host
|
|
290
|
+
// sees the final boundary instead of waiting out the heartbeat grace.
|
|
291
|
+
async function shutdown(): Promise<void> {
|
|
292
|
+
try {
|
|
293
|
+
await closeBrowser();
|
|
294
|
+
} finally {
|
|
295
|
+
await telemetry.flushOnShutdown();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
process.on('SIGTERM', () => {
|
|
299
|
+
void shutdown();
|
|
300
|
+
});
|
|
301
|
+
process.on('SIGINT', () => {
|
|
302
|
+
void shutdown();
|
|
303
|
+
});
|
|
@@ -5,8 +5,14 @@
|
|
|
5
5
|
FROM mcr.microsoft.com/playwright:v1.59.1-noble AS builder
|
|
6
6
|
|
|
7
7
|
WORKDIR /app
|
|
8
|
-
COPY package.json
|
|
9
|
-
|
|
8
|
+
COPY package.json ./
|
|
9
|
+
# The generator does not emit a package-lock.json, so use `npm install` (not
|
|
10
|
+
# `npm ci`, which fails without a lockfile). --ignore-scripts: a malicious
|
|
11
|
+
# transitive dependency's install lifecycle hook (preinstall/install/postinstall)
|
|
12
|
+
# would otherwise run as root at build time — install-time RCE in the build
|
|
13
|
+
# container. The server's own compile is an explicit `npm run build` step below,
|
|
14
|
+
# so it does not depend on install hooks.
|
|
15
|
+
RUN npm install --ignore-scripts
|
|
10
16
|
COPY tsconfig.json ./
|
|
11
17
|
COPY src/ src/
|
|
12
18
|
RUN npm run build
|
|
@@ -20,8 +26,11 @@ ENV PORT=3000
|
|
|
20
26
|
ENV HEADLESS=true
|
|
21
27
|
|
|
22
28
|
WORKDIR /app
|
|
23
|
-
COPY package.json
|
|
24
|
-
|
|
29
|
+
COPY package.json ./
|
|
30
|
+
# Runtime deps only (no lockfile emitted → `npm install`); --ignore-scripts
|
|
31
|
+
# blocks dependency install hooks from running as root. The Playwright browser
|
|
32
|
+
# binaries ship in the base image, so no install-time download lifecycle is needed.
|
|
33
|
+
RUN npm install --omit=dev --ignore-scripts
|
|
25
34
|
|
|
26
35
|
COPY --from=builder /app/dist dist/
|
|
27
36
|
|
|
@@ -9,6 +9,14 @@ IDLE_TIMEOUT_MS={{browserConfig.idleTimeoutMs}}
|
|
|
9
9
|
VIEWPORT_WIDTH={{browserConfig.viewport.width}}
|
|
10
10
|
VIEWPORT_HEIGHT={{browserConfig.viewport.height}}
|
|
11
11
|
# USER_AGENT=
|
|
12
|
+
|
|
13
|
+
# Browser-session telemetry (managed hosting only — leave UNSET to self-host).
|
|
14
|
+
# When the host sets these, the server reports exact browser-session lifetime
|
|
15
|
+
# for metering. It NEVER phones home unless MCPMAKE_TELEMETRY_URL is set.
|
|
16
|
+
# MCPMAKE_TELEMETRY_URL=
|
|
17
|
+
# MCPMAKE_TELEMETRY_TOKEN=
|
|
18
|
+
# MCPMAKE_SERVER_SLUG=
|
|
19
|
+
# MCPMAKE_TELEMETRY_HEARTBEAT_MS=30000
|
|
12
20
|
{{#if (eq transport "http")}}
|
|
13
21
|
|
|
14
22
|
# Transport: "stdio" or "http"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
2
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
5
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
@@ -28,6 +29,47 @@ function createMcpServer(): McpServer {
|
|
|
28
29
|
return server;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/* Bearer authentication — mirrors the API/node server. Constant-time compare of
|
|
33
|
+
* the `Authorization: Bearer <token>` header against MCP_AUTH_TOKEN.
|
|
34
|
+
*
|
|
35
|
+
* Fail CLOSED: when MCP_AUTH_TOKEN is unset, authenticated routes are denied
|
|
36
|
+
* unless the operator opts in with MCP_ALLOW_UNAUTHENTICATED === 'true' (a
|
|
37
|
+
* local/dev escape hatch). We warn once so the open posture is loud. The token
|
|
38
|
+
* is never read from the query string — URLs leak through logs and referrers. */
|
|
39
|
+
let unauthWarned = false;
|
|
40
|
+
function isAuthorized(req: http.IncomingMessage): boolean {
|
|
41
|
+
const expected = process.env.MCP_AUTH_TOKEN;
|
|
42
|
+
if (!expected) {
|
|
43
|
+
if (process.env.MCP_ALLOW_UNAUTHENTICATED === 'true') {
|
|
44
|
+
if (!unauthWarned) {
|
|
45
|
+
unauthWarned = true;
|
|
46
|
+
console.error(
|
|
47
|
+
'SECURITY: MCP_AUTH_TOKEN is unset and MCP_ALLOW_UNAUTHENTICATED=true — ' +
|
|
48
|
+
'serving authenticated routes WITHOUT a bearer token. ' +
|
|
49
|
+
'Set MCP_AUTH_TOKEN before exposing this endpoint.',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
if (!unauthWarned) {
|
|
55
|
+
unauthWarned = true;
|
|
56
|
+
console.error(
|
|
57
|
+
'SECURITY: MCP_AUTH_TOKEN is unset — denying all authenticated routes. ' +
|
|
58
|
+
'Set MCP_AUTH_TOKEN, or set MCP_ALLOW_UNAUTHENTICATED=true for local/dev only.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const header = req.headers.authorization;
|
|
64
|
+
if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
|
|
65
|
+
const presented = header.slice(7).trim();
|
|
66
|
+
if (!presented) return false;
|
|
67
|
+
const a = Buffer.from(presented);
|
|
68
|
+
const b = Buffer.from(expected);
|
|
69
|
+
if (a.length !== b.length) return false;
|
|
70
|
+
return crypto.timingSafeEqual(a, b);
|
|
71
|
+
}
|
|
72
|
+
|
|
31
73
|
let isReady = false;
|
|
32
74
|
|
|
33
75
|
if (process.env.TRANSPORT === 'http') {
|
|
@@ -50,6 +92,17 @@ if (process.env.TRANSPORT === 'http') {
|
|
|
50
92
|
}
|
|
51
93
|
|
|
52
94
|
if (url.pathname === '/mcp') {
|
|
95
|
+
// Bearer auth (in addition to the origin check below). /health and /ready
|
|
96
|
+
// above stay open for liveness/readiness probes.
|
|
97
|
+
if (!isAuthorized(req)) {
|
|
98
|
+
res.writeHead(401, {
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
'WWW-Authenticate': 'Bearer',
|
|
101
|
+
});
|
|
102
|
+
res.end(JSON.stringify({ error: 'Unauthorized: missing or invalid bearer token' }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
53
106
|
if (allowedOrigin) {
|
|
54
107
|
// Compare host-to-host exactly (DNS-rebinding protection). A substring
|
|
55
108
|
// match would let "evil-{ALLOWED_ORIGIN}.attacker.com" through.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-session telemetry for exact metering on managed hosting.
|
|
3
|
+
*
|
|
4
|
+
* NO-OP unless MCPMAKE_TELEMETRY_URL is set — a self-hosted or local server
|
|
5
|
+
* never phones home. When the cloud injects the ingest URL + token + slug, this
|
|
6
|
+
* batches session_start / heartbeat / session_end events and POSTs them so the
|
|
7
|
+
* host can bill the REAL resident browser lifetime instead of approximating it
|
|
8
|
+
* from request latency.
|
|
9
|
+
*
|
|
10
|
+
* Strictly best-effort and isolated: every network/serialization error is
|
|
11
|
+
* swallowed. Telemetry must NEVER affect the MCP server's behavior. Dropped
|
|
12
|
+
* events can only ever UNDER-bill (the host applies a heartbeat grace + an
|
|
13
|
+
* independent proxy-activity clamp), never break a tool call.
|
|
14
|
+
*
|
|
15
|
+
* An OPEN session emits a heartbeat on every flush: an open session means a
|
|
16
|
+
* resident Chromium context, which is the billable cost — independent of how
|
|
17
|
+
* often tools are called. The host treats "last heartbeat + grace" as the close
|
|
18
|
+
* time if a session_end is ever lost (e.g. the server is killed).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type TelemetryEventType = 'session_start' | 'heartbeat' | 'session_end';
|
|
22
|
+
|
|
23
|
+
interface TelemetryEvent {
|
|
24
|
+
type: TelemetryEventType;
|
|
25
|
+
sessionId: string;
|
|
26
|
+
ts: number;
|
|
27
|
+
reason?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TELEMETRY_URL = process.env.MCPMAKE_TELEMETRY_URL;
|
|
31
|
+
const TELEMETRY_TOKEN = process.env.MCPMAKE_TELEMETRY_TOKEN ?? '';
|
|
32
|
+
const SERVER_SLUG = process.env.MCPMAKE_SERVER_SLUG ?? '';
|
|
33
|
+
const HEARTBEAT_MS = (() => {
|
|
34
|
+
const n = parseInt(process.env.MCPMAKE_TELEMETRY_HEARTBEAT_MS ?? '30000', 10);
|
|
35
|
+
return Number.isFinite(n) && n >= 1000 ? n : 30000;
|
|
36
|
+
})();
|
|
37
|
+
const POST_TIMEOUT_MS = 5000;
|
|
38
|
+
const MAX_BUFFER = 1000;
|
|
39
|
+
|
|
40
|
+
const ENABLED = Boolean(TELEMETRY_URL) && Boolean(TELEMETRY_TOKEN) && Boolean(SERVER_SLUG);
|
|
41
|
+
|
|
42
|
+
const buffer: TelemetryEvent[] = [];
|
|
43
|
+
const openSessions = new Set<string>();
|
|
44
|
+
let flushTimer: ReturnType<typeof setInterval> | undefined;
|
|
45
|
+
|
|
46
|
+
function pushEvent(ev: TelemetryEvent): void {
|
|
47
|
+
buffer.push(ev);
|
|
48
|
+
// Bound memory if the ingest is unreachable for a long time: keep the newest.
|
|
49
|
+
if (buffer.length > MAX_BUFFER) buffer.splice(0, buffer.length - MAX_BUFFER);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureTimer(): void {
|
|
53
|
+
if (!ENABLED || flushTimer) return;
|
|
54
|
+
flushTimer = setInterval(() => {
|
|
55
|
+
void flush();
|
|
56
|
+
}, HEARTBEAT_MS);
|
|
57
|
+
// Never keep the process alive just for telemetry.
|
|
58
|
+
flushTimer.unref?.();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Record that a browser session became resident. */
|
|
62
|
+
export function sessionStarted(sessionId: string): void {
|
|
63
|
+
if (!ENABLED) return;
|
|
64
|
+
openSessions.add(sessionId);
|
|
65
|
+
pushEvent({ type: 'session_start', sessionId, ts: Date.now() });
|
|
66
|
+
ensureTimer();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Record that a browser session was torn down (idle | explicit | shutdown). */
|
|
70
|
+
export function sessionEnded(sessionId: string, reason: string): void {
|
|
71
|
+
if (!ENABLED) return;
|
|
72
|
+
openSessions.delete(sessionId);
|
|
73
|
+
pushEvent({ type: 'session_end', sessionId, ts: Date.now(), reason });
|
|
74
|
+
// Flush promptly so the host can finalize this session's billing.
|
|
75
|
+
void flush();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function flush(): Promise<void> {
|
|
79
|
+
if (!ENABLED) return;
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
// Heartbeat every still-open session — proof the browser is still resident.
|
|
82
|
+
for (const id of openSessions) {
|
|
83
|
+
pushEvent({ type: 'heartbeat', sessionId: id, ts: now });
|
|
84
|
+
}
|
|
85
|
+
if (buffer.length === 0) return;
|
|
86
|
+
const events = buffer.splice(0, buffer.length);
|
|
87
|
+
try {
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
const timer = setTimeout(() => controller.abort(), POST_TIMEOUT_MS);
|
|
90
|
+
try {
|
|
91
|
+
await fetch(TELEMETRY_URL as string, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
Authorization: `Bearer ${TELEMETRY_TOKEN}`,
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify({ v: 1, slug: SERVER_SLUG, events }),
|
|
98
|
+
signal: controller.signal,
|
|
99
|
+
});
|
|
100
|
+
} finally {
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// Swallow: telemetry is best-effort and must never affect the server.
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Best-effort final flush on shutdown: end every open session and send. */
|
|
109
|
+
export async function flushOnShutdown(reason = 'shutdown'): Promise<void> {
|
|
110
|
+
if (!ENABLED) return;
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
for (const id of [...openSessions]) {
|
|
113
|
+
openSessions.delete(id);
|
|
114
|
+
pushEvent({ type: 'session_end', sessionId: id, ts: now, reason });
|
|
115
|
+
}
|
|
116
|
+
await flush();
|
|
117
|
+
}
|
|
@@ -68,7 +68,9 @@ export function getAuthHeaders(
|
|
|
68
68
|
}
|
|
69
69
|
{{/if}}
|
|
70
70
|
{{#if (eq type "oauth2")}}
|
|
71
|
-
// OAuth2 —
|
|
71
|
+
// OAuth2 — non-interactive grants only (client credentials / refresh /
|
|
72
|
+
// pre-obtained OAUTH2_TOKEN). The interactive authorization-code flow is
|
|
73
|
+
// unsupported: a headless server has no /callback route — see oauth.ts.
|
|
72
74
|
if (schemeApplies(requirement, {{{json schemeName}}})) {
|
|
73
75
|
try {
|
|
74
76
|
const token = await getAccessToken({
|
|
@@ -76,12 +78,14 @@ export function getAuthHeaders(
|
|
|
76
78
|
clientSecret: config.oauth2ClientSecret,
|
|
77
79
|
authorizationUrl: config.oauth2AuthorizationUrl ?? '',
|
|
78
80
|
tokenUrl: config.oauth2TokenUrl ?? '',
|
|
79
|
-
scopes:
|
|
80
|
-
redirectUri: config.oauth2RedirectUri ?? '
|
|
81
|
+
scopes: config.oauth2Scopes,
|
|
82
|
+
redirectUri: config.oauth2RedirectUri ?? '',
|
|
81
83
|
});
|
|
82
84
|
headers['Authorization'] = `Bearer ${token}`;
|
|
83
|
-
} catch {
|
|
84
|
-
//
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Token fetch failed — fall back to a pre-obtained token if present.
|
|
87
|
+
// Surface the cause (never the token/secret) so it is not swallowed.
|
|
88
|
+
console.error('OAuth token fetch failed, falling back to OAUTH2_TOKEN:', String(err));
|
|
85
89
|
if (config.oauth2Token) {
|
|
86
90
|
headers['Authorization'] = `Bearer ${config.oauth2Token}`;
|
|
87
91
|
}
|
|
@@ -22,6 +22,9 @@ export interface AppConfig {
|
|
|
22
22
|
oauth2AuthorizationUrl?: string;
|
|
23
23
|
oauth2TokenUrl?: string;
|
|
24
24
|
oauth2RedirectUri?: string;
|
|
25
|
+
/** OAuth scopes requested on token grants. Defaults to the spec-declared
|
|
26
|
+
* scopes; override at runtime with OAUTH2_SCOPES (space-separated). */
|
|
27
|
+
oauth2Scopes: string[];
|
|
25
28
|
{{/if}}
|
|
26
29
|
{{/each}}
|
|
27
30
|
maxRetries: number;
|
|
@@ -76,6 +79,24 @@ function loadDefaultHeaders(): Record<string, string> {
|
|
|
76
79
|
}
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
{{#each authSchemes}}
|
|
83
|
+
{{#if (eq type "oauth2")}}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the OAuth scopes requested on token grants. Defaults to the
|
|
86
|
+
* spec-declared scopes baked in at generation time; `OAUTH2_SCOPES`
|
|
87
|
+
* (space-separated) overrides them at runtime. Empty/whitespace tokens are
|
|
88
|
+
* dropped so a stray space never sends an empty scope.
|
|
89
|
+
*/
|
|
90
|
+
function resolveOAuthScopes(): string[] {
|
|
91
|
+
const raw = process.env.OAUTH2_SCOPES;
|
|
92
|
+
if (raw !== undefined) {
|
|
93
|
+
return raw.split(/\s+/).filter((s) => s.length > 0);
|
|
94
|
+
}
|
|
95
|
+
return {{#if scopes}}{{{json scopes}}}{{else}}[]{{/if}};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
{{/if}}
|
|
99
|
+
{{/each}}
|
|
79
100
|
export function loadConfig(): AppConfig {
|
|
80
101
|
return {
|
|
81
102
|
baseUrl: resolveBaseUrl(),
|
|
@@ -100,6 +121,7 @@ export function loadConfig(): AppConfig {
|
|
|
100
121
|
oauth2AuthorizationUrl: process.env.OAUTH2_AUTHORIZATION_URL,
|
|
101
122
|
oauth2TokenUrl: process.env.OAUTH2_TOKEN_URL,
|
|
102
123
|
oauth2RedirectUri: process.env.OAUTH2_REDIRECT_URI,
|
|
124
|
+
oauth2Scopes: resolveOAuthScopes(),
|
|
103
125
|
{{/if}}
|
|
104
126
|
{{/each}}
|
|
105
127
|
maxRetries: parseInt(process.env.MAX_RETRIES ?? '3', 10),
|
|
@@ -2,8 +2,14 @@ FROM node:20-alpine AS builder
|
|
|
2
2
|
|
|
3
3
|
WORKDIR /app
|
|
4
4
|
|
|
5
|
-
COPY package.json
|
|
6
|
-
|
|
5
|
+
COPY package.json ./
|
|
6
|
+
# The generator does not emit a package-lock.json, so use `npm install` (not
|
|
7
|
+
# `npm ci`, which fails without a lockfile). --ignore-scripts: a malicious
|
|
8
|
+
# transitive dependency's install lifecycle hook (preinstall/install/postinstall)
|
|
9
|
+
# would otherwise run as root at build time — install-time RCE in the build
|
|
10
|
+
# container. The server's own compile is an explicit `npm run build` step below,
|
|
11
|
+
# so it does not depend on install hooks.
|
|
12
|
+
RUN npm install --ignore-scripts
|
|
7
13
|
|
|
8
14
|
COPY tsconfig.json ./
|
|
9
15
|
COPY src/ src/
|
|
@@ -19,8 +25,10 @@ ENV PORT=3000
|
|
|
19
25
|
|
|
20
26
|
WORKDIR /app
|
|
21
27
|
|
|
22
|
-
COPY package.json
|
|
23
|
-
|
|
28
|
+
COPY package.json ./
|
|
29
|
+
# Runtime deps only (no lockfile emitted → `npm install`); --ignore-scripts
|
|
30
|
+
# blocks dependency install hooks from running as root in the final image.
|
|
31
|
+
RUN npm install --omit=dev --ignore-scripts
|
|
24
32
|
|
|
25
33
|
COPY --from=builder /app/dist dist/
|
|
26
34
|
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* OAuth 2.1 token management.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Supports the non-interactive grants a headless generated server can actually
|
|
5
|
+
* complete: Client Credentials (machine-to-machine), refresh-token rotation,
|
|
6
|
+
* and a pre-obtained token supplied via OAUTH2_TOKEN.
|
|
7
|
+
*
|
|
8
|
+
* The interactive Authorization-Code + PKCE flow is NOT supported: a generated
|
|
9
|
+
* server has no /callback route, no browser, and no way to drive a user consent
|
|
10
|
+
* redirect. Operators authenticate by providing OAUTH2_TOKEN, or
|
|
11
|
+
* OAUTH2_CLIENT_ID + OAUTH2_CLIENT_SECRET for the client-credentials grant.
|
|
4
12
|
*/
|
|
5
13
|
|
|
6
|
-
import crypto from 'node:crypto';
|
|
7
|
-
|
|
8
14
|
export interface OAuthConfig {
|
|
9
15
|
clientId: string;
|
|
10
16
|
clientSecret?: string;
|
|
@@ -38,8 +44,10 @@ export async function getAccessToken(config: OAuthConfig): Promise<string> {
|
|
|
38
44
|
const token = await refreshToken(config, cachedToken.refreshToken);
|
|
39
45
|
cacheToken(token);
|
|
40
46
|
return token.access_token;
|
|
41
|
-
} catch {
|
|
42
|
-
// Refresh failed — fall through to re-authenticate
|
|
47
|
+
} catch (err) {
|
|
48
|
+
// Refresh failed — fall through to re-authenticate. Surface the cause
|
|
49
|
+
// (never the token/secret) so the failure is observable, not silent.
|
|
50
|
+
console.error('OAuth token refresh failed, re-authenticating:', String(err));
|
|
43
51
|
}
|
|
44
52
|
}
|
|
45
53
|
|
|
@@ -108,62 +116,6 @@ async function refreshToken(config: OAuthConfig, refreshTokenValue: string): Pro
|
|
|
108
116
|
return response.json() as Promise<TokenResponse>;
|
|
109
117
|
}
|
|
110
118
|
|
|
111
|
-
/**
|
|
112
|
-
* Generate PKCE code verifier and challenge for Authorization Code flow.
|
|
113
|
-
*/
|
|
114
|
-
export function generatePkce(): { codeVerifier: string; codeChallenge: string } {
|
|
115
|
-
const codeVerifier = crypto.randomBytes(32).toString('base64url');
|
|
116
|
-
const codeChallenge = crypto
|
|
117
|
-
.createHash('sha256')
|
|
118
|
-
.update(codeVerifier)
|
|
119
|
-
.digest('base64url');
|
|
120
|
-
return { codeVerifier, codeChallenge };
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Build the authorization URL for the Authorization Code + PKCE flow.
|
|
125
|
-
*/
|
|
126
|
-
export function buildAuthorizationUrl(config: OAuthConfig, state: string, codeChallenge: string): string {
|
|
127
|
-
const params = new URLSearchParams({
|
|
128
|
-
response_type: 'code',
|
|
129
|
-
client_id: config.clientId,
|
|
130
|
-
redirect_uri: config.redirectUri,
|
|
131
|
-
state,
|
|
132
|
-
code_challenge: codeChallenge,
|
|
133
|
-
code_challenge_method: 'S256',
|
|
134
|
-
...(config.scopes.length > 0 ? { scope: config.scopes.join(' ') } : {}),
|
|
135
|
-
});
|
|
136
|
-
return `${config.authorizationUrl}?${params.toString()}`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Exchange an authorization code for tokens (PKCE flow).
|
|
141
|
-
*/
|
|
142
|
-
export async function exchangeCode(config: OAuthConfig, code: string, codeVerifier: string): Promise<TokenResponse> {
|
|
143
|
-
const params = new URLSearchParams({
|
|
144
|
-
grant_type: 'authorization_code',
|
|
145
|
-
client_id: config.clientId,
|
|
146
|
-
code,
|
|
147
|
-
redirect_uri: config.redirectUri,
|
|
148
|
-
code_verifier: codeVerifier,
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
const response = await fetch(config.tokenUrl, {
|
|
152
|
-
method: 'POST',
|
|
153
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
154
|
-
body: params.toString(),
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
if (!response.ok) {
|
|
158
|
-
const body = await response.text();
|
|
159
|
-
throw new Error(`OAuth code exchange failed (${response.status}): ${body}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const token = await response.json() as TokenResponse;
|
|
163
|
-
cacheToken(token);
|
|
164
|
-
return token;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
119
|
function cacheToken(token: TokenResponse): void {
|
|
168
120
|
cachedToken = {
|
|
169
121
|
accessToken: token.access_token,
|
|
@@ -174,15 +126,17 @@ function cacheToken(token: TokenResponse): void {
|
|
|
174
126
|
|
|
175
127
|
/**
|
|
176
128
|
* OAuth Authorization Server Metadata (RFC 8414).
|
|
129
|
+
*
|
|
130
|
+
* Advertises only the grants this server can actually complete: client
|
|
131
|
+
* credentials and refresh-token (plus a pre-obtained OAUTH2_TOKEN). The
|
|
132
|
+
* interactive authorization-code flow is intentionally omitted — there is no
|
|
133
|
+
* /callback route to complete it (see the file header).
|
|
177
134
|
*/
|
|
178
135
|
export function getAuthServerMetadata(config: OAuthConfig, issuer: string): Record<string, unknown> {
|
|
179
136
|
return {
|
|
180
137
|
issuer,
|
|
181
|
-
authorization_endpoint: config.authorizationUrl,
|
|
182
138
|
token_endpoint: config.tokenUrl,
|
|
183
|
-
|
|
184
|
-
grant_types_supported: ['authorization_code', 'client_credentials', 'refresh_token'],
|
|
185
|
-
code_challenge_methods_supported: ['S256'],
|
|
139
|
+
grant_types_supported: ['client_credentials', 'refresh_token'],
|
|
186
140
|
scopes_supported: config.scopes,
|
|
187
141
|
};
|
|
188
142
|
}
|
|
@@ -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';
|
|
@@ -302,9 +326,10 @@ if (transportMode === 'http') {
|
|
|
302
326
|
|
|
303
327
|
/* ── Authentication ─────────────────────────────────────────────
|
|
304
328
|
* Every endpoint except /health, /ready{{#if hasOAuth}}, and the public
|
|
305
|
-
* OAuth metadata{{/if}} requires a valid bearer token
|
|
306
|
-
*
|
|
307
|
-
*
|
|
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. */
|
|
308
333
|
const isPublicPath =
|
|
309
334
|
url.pathname === '/health' ||
|
|
310
335
|
url.pathname === '/ready'{{#if hasOAuth}} ||
|
|
@@ -353,7 +378,7 @@ if (transportMode === 'http') {
|
|
|
353
378
|
clientSecret: config.oauth2ClientSecret,
|
|
354
379
|
authorizationUrl,
|
|
355
380
|
tokenUrl,
|
|
356
|
-
scopes:
|
|
381
|
+
scopes: config.oauth2Scopes,
|
|
357
382
|
redirectUri: config.oauth2RedirectUri ?? '',
|
|
358
383
|
},
|
|
359
384
|
issuer,
|
|
@@ -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` });
|