@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.
Files changed (184) hide show
  1. package/dist/analyzer/dom-parser.d.ts +35 -0
  2. package/dist/analyzer/dom-parser.d.ts.map +1 -1
  3. package/dist/analyzer/dom-parser.js +107 -6
  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 +122 -16
  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 +113 -6
  26. package/dist/emitter/index.js.map +1 -1
  27. package/dist/emitter/project-scaffolder.d.ts +13 -0
  28. package/dist/emitter/project-scaffolder.d.ts.map +1 -1
  29. package/dist/emitter/project-scaffolder.js +29 -0
  30. package/dist/emitter/project-scaffolder.js.map +1 -1
  31. package/dist/emitter/python-template-loader.d.ts.map +1 -1
  32. package/dist/emitter/python-template-loader.js +10 -0
  33. package/dist/emitter/python-template-loader.js.map +1 -1
  34. package/dist/emitter/python-templates/dockerfile.hbs +4 -1
  35. package/dist/emitter/python-templates/server.py.hbs +82 -8
  36. package/dist/emitter/site-scaffolder.d.ts.map +1 -1
  37. package/dist/emitter/site-scaffolder.js +28 -1
  38. package/dist/emitter/site-scaffolder.js.map +1 -1
  39. package/dist/emitter/site-templates/browser-manager.ts.hbs +104 -34
  40. package/dist/emitter/site-templates/dockerfile.hbs +13 -4
  41. package/dist/emitter/site-templates/env.example.hbs +8 -0
  42. package/dist/emitter/site-templates/server-main-http.ts.hbs +77 -6
  43. package/dist/emitter/site-templates/telemetry.ts.hbs +117 -0
  44. package/dist/emitter/site-templates/tool-handler-form.ts.hbs +10 -8
  45. package/dist/emitter/template-loader.d.ts.map +1 -1
  46. package/dist/emitter/template-loader.js +9 -0
  47. package/dist/emitter/template-loader.js.map +1 -1
  48. package/dist/emitter/templates/auth-provider.ts.hbs +86 -21
  49. package/dist/emitter/templates/config.ts.hbs +29 -0
  50. package/dist/emitter/templates/dockerfile.hbs +12 -4
  51. package/dist/emitter/templates/env.example.hbs +6 -0
  52. package/dist/emitter/templates/http-executor.ts.hbs +150 -7
  53. package/dist/emitter/templates/oauth.ts.hbs +19 -65
  54. package/dist/emitter/templates/server-main-http.ts.hbs +93 -12
  55. package/dist/emitter/templates/task-handlers.ts.hbs +40 -1
  56. package/dist/emitter/templates/tool-handler.ts.hbs +37 -2
  57. package/dist/emitter/templates/tool-test.ts.hbs +9 -1
  58. package/dist/emitter/worker-templates/config.ts.hbs +7 -0
  59. package/dist/emitter/worker-templates/tool-handler.ts.hbs +37 -2
  60. package/dist/emitter/worker-templates/worker.ts.hbs +119 -25
  61. package/dist/generator/spec-generator.d.ts.map +1 -1
  62. package/dist/generator/spec-generator.js +9 -54
  63. package/dist/generator/spec-generator.js.map +1 -1
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +1 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/llm/anthropic-provider.d.ts +14 -0
  69. package/dist/llm/anthropic-provider.d.ts.map +1 -0
  70. package/dist/llm/anthropic-provider.js +50 -0
  71. package/dist/llm/anthropic-provider.js.map +1 -0
  72. package/dist/llm/index.d.ts +19 -0
  73. package/dist/llm/index.d.ts.map +1 -0
  74. package/dist/llm/index.js +73 -0
  75. package/dist/llm/index.js.map +1 -0
  76. package/dist/llm/openai-provider.d.ts +46 -0
  77. package/dist/llm/openai-provider.d.ts.map +1 -0
  78. package/dist/llm/openai-provider.js +221 -0
  79. package/dist/llm/openai-provider.js.map +1 -0
  80. package/dist/llm/types.d.ts +61 -0
  81. package/dist/llm/types.d.ts.map +1 -0
  82. package/dist/llm/types.js +16 -0
  83. package/dist/llm/types.js.map +1 -0
  84. package/dist/parser/har-filter.d.ts +7 -0
  85. package/dist/parser/har-filter.d.ts.map +1 -1
  86. package/dist/parser/har-filter.js +107 -1
  87. package/dist/parser/har-filter.js.map +1 -1
  88. package/dist/parser/index.d.ts +1 -1
  89. package/dist/parser/index.d.ts.map +1 -1
  90. package/dist/parser/index.js +1 -1
  91. package/dist/parser/index.js.map +1 -1
  92. package/dist/parser/openapi-loader.d.ts.map +1 -1
  93. package/dist/parser/openapi-loader.js +36 -2
  94. package/dist/parser/openapi-loader.js.map +1 -1
  95. package/dist/parser/operation-extractor.d.ts.map +1 -1
  96. package/dist/parser/operation-extractor.js +12 -1
  97. package/dist/parser/operation-extractor.js.map +1 -1
  98. package/dist/parser/overlay-loader.d.ts.map +1 -1
  99. package/dist/parser/overlay-loader.js +11 -2
  100. package/dist/parser/overlay-loader.js.map +1 -1
  101. package/dist/parser/postman-loader.d.ts.map +1 -1
  102. package/dist/parser/postman-loader.js +6 -1
  103. package/dist/parser/postman-loader.js.map +1 -1
  104. package/dist/parser/schema-converter.d.ts +13 -2
  105. package/dist/parser/schema-converter.d.ts.map +1 -1
  106. package/dist/parser/schema-converter.js +64 -42
  107. package/dist/parser/schema-converter.js.map +1 -1
  108. package/dist/plugins/loader.d.ts +5 -1
  109. package/dist/plugins/loader.d.ts.map +1 -1
  110. package/dist/plugins/loader.js +39 -10
  111. package/dist/plugins/loader.js.map +1 -1
  112. package/dist/pricing.d.ts +5 -1
  113. package/dist/pricing.d.ts.map +1 -1
  114. package/dist/pricing.js +7 -3
  115. package/dist/pricing.js.map +1 -1
  116. package/dist/providers/index.d.ts.map +1 -1
  117. package/dist/providers/index.js +6 -21
  118. package/dist/providers/index.js.map +1 -1
  119. package/dist/recorder/browser-recorder.d.ts.map +1 -1
  120. package/dist/recorder/browser-recorder.js +89 -12
  121. package/dist/recorder/browser-recorder.js.map +1 -1
  122. package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
  123. package/dist/rescan/rescan-scheduler.js +22 -7
  124. package/dist/rescan/rescan-scheduler.js.map +1 -1
  125. package/dist/site-transformer/selector-healer.d.ts +1 -1
  126. package/dist/site-transformer/selector-healer.d.ts.map +1 -1
  127. package/dist/site-transformer/selector-healer.js +17 -18
  128. package/dist/site-transformer/selector-healer.js.map +1 -1
  129. package/dist/site-transformer/tool-generator.d.ts.map +1 -1
  130. package/dist/site-transformer/tool-generator.js +90 -22
  131. package/dist/site-transformer/tool-generator.js.map +1 -1
  132. package/dist/transformer/auth-detector.d.ts.map +1 -1
  133. package/dist/transformer/auth-detector.js +24 -5
  134. package/dist/transformer/auth-detector.js.map +1 -1
  135. package/dist/transformer/har-schema-inferrer.d.ts.map +1 -1
  136. package/dist/transformer/har-schema-inferrer.js +2 -0
  137. package/dist/transformer/har-schema-inferrer.js.map +1 -1
  138. package/dist/transformer/har-to-operations.d.ts +1 -0
  139. package/dist/transformer/har-to-operations.d.ts.map +1 -1
  140. package/dist/transformer/har-to-operations.js +42 -10
  141. package/dist/transformer/har-to-operations.js.map +1 -1
  142. package/dist/transformer/llm-namer.d.ts.map +1 -1
  143. package/dist/transformer/llm-namer.js +10 -13
  144. package/dist/transformer/llm-namer.js.map +1 -1
  145. package/dist/transformer/naming.d.ts.map +1 -1
  146. package/dist/transformer/naming.js +16 -8
  147. package/dist/transformer/naming.js.map +1 -1
  148. package/dist/transformer/resource-builder.d.ts.map +1 -1
  149. package/dist/transformer/resource-builder.js +13 -6
  150. package/dist/transformer/resource-builder.js.map +1 -1
  151. package/dist/transformer/stainless-translator.d.ts +6 -0
  152. package/dist/transformer/stainless-translator.d.ts.map +1 -1
  153. package/dist/transformer/stainless-translator.js +18 -1
  154. package/dist/transformer/stainless-translator.js.map +1 -1
  155. package/dist/transformer/tool-builder.d.ts.map +1 -1
  156. package/dist/transformer/tool-builder.js +141 -14
  157. package/dist/transformer/tool-builder.js.map +1 -1
  158. package/dist/types/index.d.ts +79 -0
  159. package/dist/types/index.d.ts.map +1 -1
  160. package/dist/types/site.d.ts +19 -1
  161. package/dist/types/site.d.ts.map +1 -1
  162. package/dist/utils/fail.d.ts.map +1 -1
  163. package/dist/utils/fail.js +22 -4
  164. package/dist/utils/fail.js.map +1 -1
  165. package/dist/utils/json-extract.d.ts +21 -0
  166. package/dist/utils/json-extract.d.ts.map +1 -0
  167. package/dist/utils/json-extract.js +67 -0
  168. package/dist/utils/json-extract.js.map +1 -0
  169. package/dist/utils/model-resolver.d.ts.map +1 -1
  170. package/dist/utils/model-resolver.js +6 -0
  171. package/dist/utils/model-resolver.js.map +1 -1
  172. package/dist/utils/sanitize.d.ts +63 -1
  173. package/dist/utils/sanitize.d.ts.map +1 -1
  174. package/dist/utils/sanitize.js +147 -2
  175. package/dist/utils/sanitize.js.map +1 -1
  176. package/dist/utils/ssrf-guard.d.ts +10 -0
  177. package/dist/utils/ssrf-guard.d.ts.map +1 -0
  178. package/dist/utils/ssrf-guard.js +130 -0
  179. package/dist/utils/ssrf-guard.js.map +1 -0
  180. package/dist/utils/watcher.d.ts +17 -1
  181. package/dist/utils/watcher.d.ts.map +1 -1
  182. package/dist/utils/watcher.js +53 -21
  183. package/dist/utils/watcher.js.map +1 -1
  184. package/package.json +2 -1
@@ -3,51 +3,92 @@ import type { AppConfig } from './config.js';
3
3
  import { getAccessToken } from './oauth.js';
4
4
  {{/if}}
5
5
 
6
+ /**
7
+ * Per-operation outbound-auth requirement (D-H2), forwarded from the tool
8
+ * handler. Mirrors the generator's ToolAuthRequirement:
9
+ * - undefined → apply every configured scheme (global default).
10
+ * - { mode: 'public' } → apply NO auth (operation declared `security: []`).
11
+ * - { mode: 'schemes' } → apply ONLY the listed OpenAPI scheme names.
12
+ */
13
+ export type AuthRequirement =
14
+ | { mode: 'public' }
15
+ | { mode: 'schemes'; schemeNames: string[] };
16
+
17
+ /** Whether the scheme named `name` should be applied for this requirement. */
18
+ function schemeApplies(req: AuthRequirement | undefined, name: string): boolean {
19
+ if (!req) return true; // no per-op requirement → legacy global behavior
20
+ if (req.mode === 'public') return false; // explicit `security: []`
21
+ return req.schemeNames.includes(name);
22
+ }
23
+
6
24
  {{#if hasOAuth}}
7
- export async function getAuthHeaders(config: AppConfig): Promise<Record<string, string>> {
25
+ export async function getAuthHeaders(
26
+ config: AppConfig,
27
+ requirement?: AuthRequirement,
28
+ ): Promise<Record<string, string>> {
8
29
  {{else}}
9
- export function getAuthHeaders(config: AppConfig): Record<string, string> {
30
+ export function getAuthHeaders(
31
+ config: AppConfig,
32
+ requirement?: AuthRequirement,
33
+ ): Record<string, string> {
10
34
  {{/if}}
11
35
  const headers: Record<string, string> = {};
12
36
 
13
37
  {{#each authSchemes}}
14
38
  {{#if (eq type "apiKey")}}
15
39
  {{#if (eq in "header")}}
16
- if (config.apiKey) {
40
+ if (config.apiKey && schemeApplies(requirement, {{{json schemeName}}})) {
17
41
  headers['{{headerName}}'] = config.apiKey;
18
42
  }
19
43
  {{/if}}
44
+ {{#if (eq in "cookie")}}
45
+ // API key sent as a cookie — appended to the Cookie header so it survives
46
+ // alongside any per-operation cookie parameters set by the handler.
47
+ if (config.apiKey && schemeApplies(requirement, {{{json schemeName}}})) {
48
+ const existing = headers['Cookie'];
49
+ const pair = '{{headerName}}=' + encodeURIComponent(config.apiKey);
50
+ headers['Cookie'] = existing ? existing + '; ' + pair : pair;
51
+ }
52
+ {{/if}}
20
53
  {{#if (eq in "query")}}
21
- // API key sent as query parameter handled in request URL construction
54
+ // API key is sent as a query parameter (`{{headerName}}`). Query-string auth is
55
+ // applied during URL construction (apiKeyQueryName/apiKeyQueryValue in config),
56
+ // not as a header, so there is nothing to add here.
22
57
  {{/if}}
23
58
  {{/if}}
24
59
  {{#if (eq type "http-bearer")}}
25
- if (config.bearerToken) {
60
+ if (config.bearerToken && schemeApplies(requirement, {{{json schemeName}}})) {
26
61
  headers['Authorization'] = `Bearer ${config.bearerToken}`;
27
62
  }
28
63
  {{/if}}
29
64
  {{#if (eq type "http-basic")}}
30
- if (config.basicUsername && config.basicPassword) {
65
+ if (config.basicUsername && config.basicPassword && schemeApplies(requirement, {{{json schemeName}}})) {
31
66
  const encoded = Buffer.from(`${config.basicUsername}:${config.basicPassword}`).toString('base64');
32
67
  headers['Authorization'] = `Basic ${encoded}`;
33
68
  }
34
69
  {{/if}}
35
70
  {{#if (eq type "oauth2")}}
36
- // OAuth2 — use token management with refresh
37
- try {
38
- const token = await getAccessToken({
39
- clientId: config.oauth2ClientId ?? '',
40
- clientSecret: config.oauth2ClientSecret,
41
- authorizationUrl: config.oauth2AuthorizationUrl ?? '',
42
- tokenUrl: config.oauth2TokenUrl ?? '',
43
- scopes: [],
44
- redirectUri: config.oauth2RedirectUri ?? 'http://localhost:3000/callback',
45
- });
46
- headers['Authorization'] = `Bearer ${token}`;
47
- } catch {
48
- // Fallback to pre-obtained token
49
- if (config.oauth2Token) {
50
- headers['Authorization'] = `Bearer ${config.oauth2Token}`;
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.
74
+ if (schemeApplies(requirement, {{{json schemeName}}})) {
75
+ try {
76
+ const token = await getAccessToken({
77
+ clientId: config.oauth2ClientId ?? '',
78
+ clientSecret: config.oauth2ClientSecret,
79
+ authorizationUrl: config.oauth2AuthorizationUrl ?? '',
80
+ tokenUrl: config.oauth2TokenUrl ?? '',
81
+ scopes: config.oauth2Scopes,
82
+ redirectUri: config.oauth2RedirectUri ?? '',
83
+ });
84
+ headers['Authorization'] = `Bearer ${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));
89
+ if (config.oauth2Token) {
90
+ headers['Authorization'] = `Bearer ${config.oauth2Token}`;
91
+ }
51
92
  }
52
93
  }
53
94
  {{/if}}
@@ -55,3 +96,27 @@ export function getAuthHeaders(config: AppConfig): Record<string, string> {
55
96
 
56
97
  return headers;
57
98
  }
99
+
100
+ /**
101
+ * Query-string auth parameters (apiKey-in-query schemes), keyed by query-param
102
+ * name. Applied by the tool handler during URL construction — a query parameter
103
+ * cannot be sent as a request header (D-H2). Respects the same per-operation
104
+ * requirement as the headers: a public operation gets none, a scheme-scoped one
105
+ * gets only its scheme(s).
106
+ */
107
+ export function getAuthQueryParams(
108
+ config: AppConfig,
109
+ requirement?: AuthRequirement,
110
+ ): Record<string, string> {
111
+ const params: Record<string, string> = {};
112
+ {{#each authSchemes}}
113
+ {{#if (eq type "apiKey")}}
114
+ {{#if (eq in "query")}}
115
+ if (config.apiKey && config.apiKeyQueryName && schemeApplies(requirement, {{{json schemeName}}})) {
116
+ params[config.apiKeyQueryName] = config.apiKey;
117
+ }
118
+ {{/if}}
119
+ {{/if}}
120
+ {{/each}}
121
+ return params;
122
+ }
@@ -3,6 +3,10 @@ export interface AppConfig {
3
3
  {{#each authSchemes}}
4
4
  {{#if (eq type "apiKey")}}
5
5
  apiKey?: string;
6
+ {{#if (eq in "query")}}
7
+ /** Query-parameter name the API key is appended under (apiKey-in-query auth). */
8
+ apiKeyQueryName?: string;
9
+ {{/if}}
6
10
  {{/if}}
7
11
  {{#if (eq type "http-bearer")}}
8
12
  bearerToken?: string;
@@ -18,6 +22,9 @@ export interface AppConfig {
18
22
  oauth2AuthorizationUrl?: string;
19
23
  oauth2TokenUrl?: string;
20
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[];
21
28
  {{/if}}
22
29
  {{/each}}
23
30
  maxRetries: number;
@@ -72,12 +79,33 @@ function loadDefaultHeaders(): Record<string, string> {
72
79
  }
73
80
  }
74
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}}
75
100
  export function loadConfig(): AppConfig {
76
101
  return {
77
102
  baseUrl: resolveBaseUrl(),
78
103
  {{#each authSchemes}}
79
104
  {{#if (eq type "apiKey")}}
80
105
  apiKey: process.env.{{envVarName}},
106
+ {{#if (eq in "query")}}
107
+ apiKeyQueryName: {{{json headerName}}},
108
+ {{/if}}
81
109
  {{/if}}
82
110
  {{#if (eq type "http-bearer")}}
83
111
  bearerToken: process.env.{{envVarName}},
@@ -93,6 +121,7 @@ export function loadConfig(): AppConfig {
93
121
  oauth2AuthorizationUrl: process.env.OAUTH2_AUTHORIZATION_URL,
94
122
  oauth2TokenUrl: process.env.OAUTH2_TOKEN_URL,
95
123
  oauth2RedirectUri: process.env.OAUTH2_REDIRECT_URI,
124
+ oauth2Scopes: resolveOAuthScopes(),
96
125
  {{/if}}
97
126
  {{/each}}
98
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
 
@@ -49,4 +49,10 @@ MCP_AUTH_TOKEN=
49
49
  # — any request can hit any instance). Set to "true" for stateful per-session
50
50
  # (Mcp-Session-Id) mode if a client needs resumable streams.
51
51
  MCP_STATEFUL=
52
+ # Stateful-session bounds (only used when MCP_STATEFUL=true). The server caps the
53
+ # number of live sessions, evicts ones idle past MCP_SESSION_IDLE_MS, and sweeps
54
+ # on a MCP_SESSION_REAP_MS timer so abandoned sessions can't exhaust memory.
55
+ # MCP_MAX_SESSIONS=1000
56
+ # MCP_SESSION_IDLE_MS=300000
57
+ # MCP_SESSION_REAP_MS=60000
52
58
  {{/if}}
@@ -1,4 +1,4 @@
1
- import { getAuthHeaders } from './auth.js';
1
+ import { getAuthHeaders, type AuthRequirement } from './auth.js';
2
2
  import { traceHeaders } from './trace.js';
3
3
  import type { AppConfig } from './config.js';
4
4
 
@@ -7,6 +7,14 @@ export interface RequestOptions {
7
7
  url: string;
8
8
  body?: unknown;
9
9
  contentType?: string;
10
+ /**
11
+ * How to serialize `body` for the upstream request:
12
+ * - `json` (default): JSON.stringify, Content-Type stays as given.
13
+ * - `form`: URLSearchParams (application/x-www-form-urlencoded).
14
+ * - `multipart`: FormData (the runtime sets multipart Content-Type + boundary;
15
+ * a per-field { __file__, content, filename } object is sent as a file part).
16
+ */
17
+ bodyEncoding?: 'json' | 'form' | 'multipart';
10
18
  headers: Record<string, string>;
11
19
  config: AppConfig;
12
20
  /**
@@ -14,6 +22,71 @@ export interface RequestOptions {
14
22
  * absent and `MCP_IDEMPOTENCY_AUTO=true`, a UUID is generated automatically.
15
23
  */
16
24
  idempotencyKey?: string;
25
+ /**
26
+ * Per-operation outbound-auth requirement (D-H2). Forwarded to getAuthHeaders
27
+ * so a public operation (`security: []`) sends no auth and a scheme-scoped
28
+ * operation sends only its scheme(s). Omitted → every configured scheme.
29
+ */
30
+ authRequirement?: AuthRequirement;
31
+ }
32
+
33
+ /**
34
+ * Hard per-request deadline (ms). Without it a stalled upstream keeps a tool
35
+ * call (and, in async-task mode, the background promise) alive indefinitely.
36
+ * Override with MCP_REQUEST_TIMEOUT_MS; defaults to 30s.
37
+ */
38
+ const REQUEST_TIMEOUT_MS = (() => {
39
+ const raw = typeof process !== 'undefined' ? process.env?.MCP_REQUEST_TIMEOUT_MS : undefined;
40
+ const n = raw ? Number(raw) : NaN;
41
+ return Number.isFinite(n) && n > 0 ? n : 30_000;
42
+ })();
43
+
44
+ /**
45
+ * Maximum upstream response body size (bytes). A malicious or runaway upstream
46
+ * can otherwise exhaust memory in a single response. Override with
47
+ * MCP_MAX_RESPONSE_BYTES; defaults to 10 MiB.
48
+ */
49
+ const MAX_RESPONSE_BYTES = (() => {
50
+ const raw = typeof process !== 'undefined' ? process.env?.MCP_MAX_RESPONSE_BYTES : undefined;
51
+ const n = raw ? Number(raw) : NaN;
52
+ return Number.isFinite(n) && n > 0 ? n : 10 * 1024 * 1024;
53
+ })();
54
+
55
+ /** Serialize a request body according to its declared encoding. */
56
+ function serializeBody(
57
+ body: unknown,
58
+ encoding: 'json' | 'form' | 'multipart' | undefined,
59
+ ): { body: string | URLSearchParams | FormData; setContentType: boolean } {
60
+ if (encoding === 'form') {
61
+ const params = new URLSearchParams();
62
+ if (body && typeof body === 'object') {
63
+ for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
64
+ if (v !== undefined && v !== null) params.append(k, String(v));
65
+ }
66
+ }
67
+ return { body: params, setContentType: true };
68
+ }
69
+ if (encoding === 'multipart') {
70
+ const form = new FormData();
71
+ if (body && typeof body === 'object') {
72
+ for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
73
+ if (v === undefined || v === null) continue;
74
+ if (
75
+ typeof v === 'object' &&
76
+ '__file__' in (v as Record<string, unknown>) &&
77
+ 'content' in (v as Record<string, unknown>)
78
+ ) {
79
+ const f = v as { content: string; filename?: string };
80
+ form.append(k, new Blob([f.content]), f.filename ?? k);
81
+ } else {
82
+ form.append(k, String(v));
83
+ }
84
+ }
85
+ }
86
+ // Let the runtime set multipart/form-data with the correct boundary.
87
+ return { body: form, setContentType: false };
88
+ }
89
+ return { body: JSON.stringify(body), setContentType: true };
17
90
  }
18
91
 
19
92
  export interface ApiResponse {
@@ -75,7 +148,7 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
75
148
  }
76
149
  lastRequestTime = Date.now();
77
150
 
78
- const authHeaders = await getAuthHeaders(config);
151
+ const authHeaders = await getAuthHeaders(config, options.authRequirement);
79
152
  // Header precedence (lowest → highest): auth, runtime default headers
80
153
  // (MCP_DEFAULT_HEADERS), W3C Trace Context, then per-call headers.
81
154
  // Propagate W3C Trace Context to the upstream API (no-op outside an HTTP
@@ -88,8 +161,15 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
88
161
  ...options.headers,
89
162
  };
90
163
 
91
- if (options.body && options.contentType) {
92
- headers['Content-Type'] = options.contentType;
164
+ // Serialize the body for its declared encoding. FormData must NOT carry a
165
+ // manual Content-Type the runtime appends the multipart boundary itself.
166
+ let serializedBody: string | URLSearchParams | FormData | undefined;
167
+ if (options.body !== undefined && options.body !== null) {
168
+ const { body, setContentType } = serializeBody(options.body, options.bodyEncoding);
169
+ serializedBody = body;
170
+ if (setContentType && options.contentType) {
171
+ headers['Content-Type'] = options.contentType;
172
+ }
93
173
  }
94
174
 
95
175
  // Idempotency-Key for mutating requests (explicit per-call, or auto when enabled).
@@ -101,11 +181,16 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
101
181
  let lastError: Error | undefined;
102
182
 
103
183
  for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
184
+ // Per-attempt hard deadline. AbortError is not retryable — a timed-out
185
+ // upstream should fail fast rather than multiply the wait by the retry count.
186
+ const controller = new AbortController();
187
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
104
188
  try {
105
189
  const response = await fetch(options.url, {
106
190
  method,
107
191
  headers,
108
- body: options.body ? JSON.stringify(options.body) : undefined,
192
+ body: serializedBody,
193
+ signal: controller.signal,
109
194
  });
110
195
 
111
196
  if (RETRYABLE_STATUS_CODES.includes(response.status) && attempt < config.maxRetries) {
@@ -134,24 +219,82 @@ export async function executeRequest(options: RequestOptions): Promise<ApiRespon
134
219
  };
135
220
  } catch (error) {
136
221
  lastError = error as Error;
222
+ // A request that hit the hard deadline is cancelled, not retried.
223
+ const isAbort =
224
+ error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
225
+ if (isAbort) {
226
+ throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`);
227
+ }
137
228
  if (error instanceof ApiError && !RETRYABLE_STATUS_CODES.includes(error.status)) {
138
229
  throw error;
139
230
  }
140
231
  if (attempt < config.maxRetries) {
141
232
  await sleep(1000 * Math.pow(2, attempt));
142
233
  }
234
+ } finally {
235
+ clearTimeout(timer);
143
236
  }
144
237
  }
145
238
 
146
239
  throw lastError ?? new Error('Request failed after retries');
147
240
  }
148
241
 
242
+ /**
243
+ * Read a response body with a hard byte cap. Streaming the body lets us abort as
244
+ * soon as the cap is exceeded instead of buffering an unbounded (or dishonest
245
+ * Content-Length) payload into memory.
246
+ */
247
+ async function readBounded(response: Response): Promise<string> {
248
+ const declared = Number(response.headers.get('content-length'));
249
+ if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
250
+ throw new Error(`Response too large (${declared} bytes > ${MAX_RESPONSE_BYTES} cap)`);
251
+ }
252
+ if (!response.body) {
253
+ return response.text();
254
+ }
255
+ const reader = response.body.getReader();
256
+ const chunks: Uint8Array[] = [];
257
+ let total = 0;
258
+ try {
259
+ for (;;) {
260
+ const { done, value } = await reader.read();
261
+ if (done) break;
262
+ if (value) {
263
+ total += value.byteLength;
264
+ if (total > MAX_RESPONSE_BYTES) {
265
+ await reader.cancel();
266
+ throw new Error(`Response exceeded ${MAX_RESPONSE_BYTES}-byte cap (truncated)`);
267
+ }
268
+ chunks.push(value);
269
+ }
270
+ }
271
+ } finally {
272
+ reader.releaseLock();
273
+ }
274
+ return new TextDecoder().decode(concatChunks(chunks, total));
275
+ }
276
+
277
+ function concatChunks(chunks: Uint8Array[], total: number): Uint8Array {
278
+ const out = new Uint8Array(total);
279
+ let offset = 0;
280
+ for (const c of chunks) {
281
+ out.set(c, offset);
282
+ offset += c.byteLength;
283
+ }
284
+ return out;
285
+ }
286
+
149
287
  async function parseResponseBody(response: Response): Promise<unknown> {
150
288
  const contentType = response.headers.get('content-type') ?? '';
289
+ const text = await readBounded(response);
151
290
  if (contentType.includes('application/json')) {
152
- return response.json();
291
+ try {
292
+ return JSON.parse(text);
293
+ } catch {
294
+ return text;
295
+ }
153
296
  }
154
- return response.text();
297
+ return text;
155
298
  }
156
299
 
157
300
  function sleep(ms: number): Promise<void> {
@@ -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
  }