@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.
Files changed (137) hide show
  1. package/dist/analyzer/dom-parser.d.ts +28 -0
  2. package/dist/analyzer/dom-parser.d.ts.map +1 -1
  3. package/dist/analyzer/dom-parser.js +77 -2
  4. package/dist/analyzer/dom-parser.js.map +1 -1
  5. package/dist/analyzer/goal-crawler.d.ts +2 -1
  6. package/dist/analyzer/goal-crawler.d.ts.map +1 -1
  7. package/dist/analyzer/goal-crawler.js +66 -17
  8. package/dist/analyzer/goal-crawler.js.map +1 -1
  9. package/dist/analyzer/semantic-analyzer.d.ts +1 -1
  10. package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
  11. package/dist/analyzer/semantic-analyzer.js +15 -16
  12. package/dist/analyzer/semantic-analyzer.js.map +1 -1
  13. package/dist/analyzer/site-crawler.d.ts.map +1 -1
  14. package/dist/analyzer/site-crawler.js +86 -12
  15. package/dist/analyzer/site-crawler.js.map +1 -1
  16. package/dist/config/mcpmake-config.d.ts.map +1 -1
  17. package/dist/config/mcpmake-config.js +5 -0
  18. package/dist/config/mcpmake-config.js.map +1 -1
  19. package/dist/emitter/code-writer.d.ts +1 -0
  20. package/dist/emitter/code-writer.d.ts.map +1 -1
  21. package/dist/emitter/code-writer.js +79 -12
  22. package/dist/emitter/code-writer.js.map +1 -1
  23. package/dist/emitter/index.d.ts +8 -0
  24. package/dist/emitter/index.d.ts.map +1 -1
  25. package/dist/emitter/index.js.map +1 -1
  26. package/dist/emitter/python-templates/dockerfile.hbs +4 -1
  27. package/dist/emitter/python-templates/server.py.hbs +4 -0
  28. package/dist/emitter/site-scaffolder.d.ts.map +1 -1
  29. package/dist/emitter/site-scaffolder.js +28 -1
  30. package/dist/emitter/site-scaffolder.js.map +1 -1
  31. package/dist/emitter/site-templates/browser-manager.ts.hbs +24 -3
  32. package/dist/emitter/site-templates/dockerfile.hbs +13 -4
  33. package/dist/emitter/site-templates/env.example.hbs +8 -0
  34. package/dist/emitter/site-templates/server-main-http.ts.hbs +53 -0
  35. package/dist/emitter/site-templates/telemetry.ts.hbs +117 -0
  36. package/dist/emitter/templates/auth-provider.ts.hbs +9 -5
  37. package/dist/emitter/templates/config.ts.hbs +22 -0
  38. package/dist/emitter/templates/dockerfile.hbs +12 -4
  39. package/dist/emitter/templates/oauth.ts.hbs +19 -65
  40. package/dist/emitter/templates/server-main-http.ts.hbs +33 -8
  41. package/dist/emitter/templates/task-handlers.ts.hbs +40 -1
  42. package/dist/emitter/worker-templates/worker.ts.hbs +88 -25
  43. package/dist/generator/spec-generator.d.ts.map +1 -1
  44. package/dist/generator/spec-generator.js +9 -54
  45. package/dist/generator/spec-generator.js.map +1 -1
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +1 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/llm/anthropic-provider.d.ts +14 -0
  51. package/dist/llm/anthropic-provider.d.ts.map +1 -0
  52. package/dist/llm/anthropic-provider.js +50 -0
  53. package/dist/llm/anthropic-provider.js.map +1 -0
  54. package/dist/llm/index.d.ts +19 -0
  55. package/dist/llm/index.d.ts.map +1 -0
  56. package/dist/llm/index.js +73 -0
  57. package/dist/llm/index.js.map +1 -0
  58. package/dist/llm/openai-provider.d.ts +46 -0
  59. package/dist/llm/openai-provider.d.ts.map +1 -0
  60. package/dist/llm/openai-provider.js +221 -0
  61. package/dist/llm/openai-provider.js.map +1 -0
  62. package/dist/llm/types.d.ts +61 -0
  63. package/dist/llm/types.d.ts.map +1 -0
  64. package/dist/llm/types.js +16 -0
  65. package/dist/llm/types.js.map +1 -0
  66. package/dist/parser/har-filter.d.ts +7 -0
  67. package/dist/parser/har-filter.d.ts.map +1 -1
  68. package/dist/parser/har-filter.js +107 -1
  69. package/dist/parser/har-filter.js.map +1 -1
  70. package/dist/parser/index.d.ts +1 -1
  71. package/dist/parser/index.d.ts.map +1 -1
  72. package/dist/parser/index.js +1 -1
  73. package/dist/parser/index.js.map +1 -1
  74. package/dist/parser/openapi-loader.d.ts.map +1 -1
  75. package/dist/parser/openapi-loader.js +36 -2
  76. package/dist/parser/openapi-loader.js.map +1 -1
  77. package/dist/parser/postman-loader.d.ts.map +1 -1
  78. package/dist/parser/postman-loader.js +6 -1
  79. package/dist/parser/postman-loader.js.map +1 -1
  80. package/dist/parser/schema-converter.d.ts.map +1 -1
  81. package/dist/parser/schema-converter.js +34 -23
  82. package/dist/parser/schema-converter.js.map +1 -1
  83. package/dist/plugins/loader.d.ts +5 -1
  84. package/dist/plugins/loader.d.ts.map +1 -1
  85. package/dist/plugins/loader.js +39 -10
  86. package/dist/plugins/loader.js.map +1 -1
  87. package/dist/recorder/browser-recorder.d.ts.map +1 -1
  88. package/dist/recorder/browser-recorder.js +50 -5
  89. package/dist/recorder/browser-recorder.js.map +1 -1
  90. package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
  91. package/dist/rescan/rescan-scheduler.js +22 -7
  92. package/dist/rescan/rescan-scheduler.js.map +1 -1
  93. package/dist/site-transformer/selector-healer.d.ts +1 -1
  94. package/dist/site-transformer/selector-healer.d.ts.map +1 -1
  95. package/dist/site-transformer/selector-healer.js +17 -18
  96. package/dist/site-transformer/selector-healer.js.map +1 -1
  97. package/dist/transformer/auth-detector.d.ts.map +1 -1
  98. package/dist/transformer/auth-detector.js +13 -1
  99. package/dist/transformer/auth-detector.js.map +1 -1
  100. package/dist/transformer/har-schema-inferrer.d.ts.map +1 -1
  101. package/dist/transformer/har-schema-inferrer.js +2 -0
  102. package/dist/transformer/har-schema-inferrer.js.map +1 -1
  103. package/dist/transformer/har-to-operations.d.ts +1 -0
  104. package/dist/transformer/har-to-operations.d.ts.map +1 -1
  105. package/dist/transformer/har-to-operations.js +42 -10
  106. package/dist/transformer/har-to-operations.js.map +1 -1
  107. package/dist/transformer/llm-namer.d.ts.map +1 -1
  108. package/dist/transformer/llm-namer.js +10 -13
  109. package/dist/transformer/llm-namer.js.map +1 -1
  110. package/dist/transformer/tool-builder.d.ts.map +1 -1
  111. package/dist/transformer/tool-builder.js +30 -1
  112. package/dist/transformer/tool-builder.js.map +1 -1
  113. package/dist/types/index.d.ts +7 -0
  114. package/dist/types/index.d.ts.map +1 -1
  115. package/dist/utils/fail.d.ts.map +1 -1
  116. package/dist/utils/fail.js +22 -4
  117. package/dist/utils/fail.js.map +1 -1
  118. package/dist/utils/json-extract.d.ts +21 -0
  119. package/dist/utils/json-extract.d.ts.map +1 -0
  120. package/dist/utils/json-extract.js +67 -0
  121. package/dist/utils/json-extract.js.map +1 -0
  122. package/dist/utils/model-resolver.d.ts.map +1 -1
  123. package/dist/utils/model-resolver.js +6 -0
  124. package/dist/utils/model-resolver.js.map +1 -1
  125. package/dist/utils/sanitize.d.ts +1 -1
  126. package/dist/utils/sanitize.d.ts.map +1 -1
  127. package/dist/utils/sanitize.js +6 -2
  128. package/dist/utils/sanitize.js.map +1 -1
  129. package/dist/utils/ssrf-guard.d.ts +10 -0
  130. package/dist/utils/ssrf-guard.d.ts.map +1 -0
  131. package/dist/utils/ssrf-guard.js +130 -0
  132. package/dist/utils/ssrf-guard.js.map +1 -0
  133. package/dist/utils/watcher.d.ts +17 -1
  134. package/dist/utils/watcher.d.ts.map +1 -1
  135. package/dist/utils/watcher.js +53 -21
  136. package/dist/utils/watcher.js.map +1 -1
  137. 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
- process.on('SIGTERM', () => closeBrowser());
282
- process.on('SIGINT', () => closeBrowser());
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 package-lock.json ./
9
- RUN npm ci
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 package-lock.json ./
24
- RUN npm ci --omit=dev
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 — use token management with refresh
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 ?? 'http://localhost:3000/callback',
81
+ scopes: config.oauth2Scopes,
82
+ redirectUri: config.oauth2RedirectUri ?? '',
81
83
  });
82
84
  headers['Authorization'] = `Bearer ${token}`;
83
- } catch {
84
- // Fallback to pre-obtained token
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 package-lock.json ./
6
- RUN npm ci
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 package-lock.json ./
23
- RUN npm ci --omit=dev
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
- * Supports Authorization Code + PKCE and Client Credentials flows.
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
- response_types_supported: ['code'],
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. Returns true when no token is
46
- * configured (auth disabled). The token is never accepted from the query
47
- * string — URLs leak through logs, history, and referrers. */
50
+ * the `Authorization: Bearer <token>` header. The token is never accepted from
51
+ * the query string URLs leak through logs, history, and referrers.
52
+ *
53
+ * Fails CLOSED: when MCP_AUTH_TOKEN is unset, every authenticated route is
54
+ * denied unless the operator explicitly opts in with MCP_ALLOW_UNAUTHENTICATED=
55
+ * true (a local/dev escape hatch — see ALLOW_UNAUTHENTICATED below). */
48
56
  function isAuthorized(req: http.IncomingMessage, expected: string | undefined): boolean {
49
- if (!expected) return true; // auth disabled when MCP_AUTH_TOKEN is unset
57
+ if (!expected) return ALLOW_UNAUTHENTICATED; // fail closed unless explicitly opted in
50
58
 
51
59
  const header = req.headers.authorization;
52
60
  if (typeof header !== 'string' || !header.startsWith('Bearer ')) return false;
@@ -166,6 +174,22 @@ if (transportMode === 'http') {
166
174
  }
167
175
  const allowedOrigin = process.env.ALLOWED_ORIGIN;
168
176
  const authToken = process.env.MCP_AUTH_TOKEN;
177
+ /* Fail closed: with no MCP_AUTH_TOKEN the server denies every authenticated
178
+ * route (401) unless the operator opted in via MCP_ALLOW_UNAUTHENTICATED=true.
179
+ * Warn loudly, once, when running wide open so it is never a silent default. */
180
+ if (!authToken) {
181
+ if (ALLOW_UNAUTHENTICATED) {
182
+ console.error(
183
+ 'WARNING: MCP_AUTH_TOKEN is not set and MCP_ALLOW_UNAUTHENTICATED=true — ' +
184
+ 'the server is running UNAUTHENTICATED. Do not use this in production.',
185
+ );
186
+ } else {
187
+ console.error(
188
+ 'MCP_AUTH_TOKEN is not set — authenticated routes will return 401. ' +
189
+ 'Set MCP_AUTH_TOKEN, or MCP_ALLOW_UNAUTHENTICATED=true for local/dev only.',
190
+ );
191
+ }
192
+ }
169
193
  /* Stateless by default (2026-07-28). MCP_STATEFUL=true keeps Mcp-Session-Id
170
194
  * sessions during the migration / for clients that need resumable streams. */
171
195
  const stateful = process.env.MCP_STATEFUL === 'true';
@@ -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 when MCP_AUTH_TOKEN
306
- * is set. This is the in-container line of defense; the hosting backend
307
- * also validates before proxying. */
329
+ * OAuth metadata{{/if}} requires a valid bearer token. With MCP_AUTH_TOKEN
330
+ * unset the server fails closed (401) unless MCP_ALLOW_UNAUTHENTICATED=true.
331
+ * This is the in-container line of defense; the hosting backend also
332
+ * validates before proxying. */
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` });