@mcpmake/core 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/analyzer/dom-parser.d.ts +35 -0
- package/dist/analyzer/dom-parser.d.ts.map +1 -1
- package/dist/analyzer/dom-parser.js +107 -6
- package/dist/analyzer/dom-parser.js.map +1 -1
- package/dist/analyzer/goal-crawler.d.ts +2 -1
- package/dist/analyzer/goal-crawler.d.ts.map +1 -1
- package/dist/analyzer/goal-crawler.js +66 -17
- package/dist/analyzer/goal-crawler.js.map +1 -1
- package/dist/analyzer/semantic-analyzer.d.ts +1 -1
- package/dist/analyzer/semantic-analyzer.d.ts.map +1 -1
- package/dist/analyzer/semantic-analyzer.js +15 -16
- package/dist/analyzer/semantic-analyzer.js.map +1 -1
- package/dist/analyzer/site-crawler.d.ts.map +1 -1
- package/dist/analyzer/site-crawler.js +122 -16
- package/dist/analyzer/site-crawler.js.map +1 -1
- package/dist/config/mcpmake-config.d.ts.map +1 -1
- package/dist/config/mcpmake-config.js +5 -0
- package/dist/config/mcpmake-config.js.map +1 -1
- package/dist/emitter/code-writer.d.ts +1 -0
- package/dist/emitter/code-writer.d.ts.map +1 -1
- package/dist/emitter/code-writer.js +79 -12
- package/dist/emitter/code-writer.js.map +1 -1
- package/dist/emitter/index.d.ts +8 -0
- package/dist/emitter/index.d.ts.map +1 -1
- package/dist/emitter/index.js +113 -6
- package/dist/emitter/index.js.map +1 -1
- package/dist/emitter/project-scaffolder.d.ts +13 -0
- package/dist/emitter/project-scaffolder.d.ts.map +1 -1
- package/dist/emitter/project-scaffolder.js +29 -0
- package/dist/emitter/project-scaffolder.js.map +1 -1
- package/dist/emitter/python-template-loader.d.ts.map +1 -1
- package/dist/emitter/python-template-loader.js +10 -0
- package/dist/emitter/python-template-loader.js.map +1 -1
- package/dist/emitter/python-templates/dockerfile.hbs +4 -1
- package/dist/emitter/python-templates/server.py.hbs +82 -8
- package/dist/emitter/site-scaffolder.d.ts.map +1 -1
- package/dist/emitter/site-scaffolder.js +28 -1
- package/dist/emitter/site-scaffolder.js.map +1 -1
- package/dist/emitter/site-templates/browser-manager.ts.hbs +104 -34
- package/dist/emitter/site-templates/dockerfile.hbs +13 -4
- package/dist/emitter/site-templates/env.example.hbs +8 -0
- package/dist/emitter/site-templates/server-main-http.ts.hbs +77 -6
- package/dist/emitter/site-templates/telemetry.ts.hbs +117 -0
- package/dist/emitter/site-templates/tool-handler-form.ts.hbs +10 -8
- package/dist/emitter/template-loader.d.ts.map +1 -1
- package/dist/emitter/template-loader.js +9 -0
- package/dist/emitter/template-loader.js.map +1 -1
- package/dist/emitter/templates/auth-provider.ts.hbs +86 -21
- package/dist/emitter/templates/config.ts.hbs +29 -0
- package/dist/emitter/templates/dockerfile.hbs +12 -4
- package/dist/emitter/templates/env.example.hbs +6 -0
- package/dist/emitter/templates/http-executor.ts.hbs +150 -7
- package/dist/emitter/templates/oauth.ts.hbs +19 -65
- package/dist/emitter/templates/server-main-http.ts.hbs +93 -12
- package/dist/emitter/templates/task-handlers.ts.hbs +40 -1
- package/dist/emitter/templates/tool-handler.ts.hbs +37 -2
- package/dist/emitter/templates/tool-test.ts.hbs +9 -1
- package/dist/emitter/worker-templates/config.ts.hbs +7 -0
- package/dist/emitter/worker-templates/tool-handler.ts.hbs +37 -2
- package/dist/emitter/worker-templates/worker.ts.hbs +119 -25
- package/dist/generator/spec-generator.d.ts.map +1 -1
- package/dist/generator/spec-generator.js +9 -54
- package/dist/generator/spec-generator.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/llm/anthropic-provider.d.ts +14 -0
- package/dist/llm/anthropic-provider.d.ts.map +1 -0
- package/dist/llm/anthropic-provider.js +50 -0
- package/dist/llm/anthropic-provider.js.map +1 -0
- package/dist/llm/index.d.ts +19 -0
- package/dist/llm/index.d.ts.map +1 -0
- package/dist/llm/index.js +73 -0
- package/dist/llm/index.js.map +1 -0
- package/dist/llm/openai-provider.d.ts +46 -0
- package/dist/llm/openai-provider.d.ts.map +1 -0
- package/dist/llm/openai-provider.js +221 -0
- package/dist/llm/openai-provider.js.map +1 -0
- package/dist/llm/types.d.ts +61 -0
- package/dist/llm/types.d.ts.map +1 -0
- package/dist/llm/types.js +16 -0
- package/dist/llm/types.js.map +1 -0
- package/dist/parser/har-filter.d.ts +7 -0
- package/dist/parser/har-filter.d.ts.map +1 -1
- package/dist/parser/har-filter.js +107 -1
- package/dist/parser/har-filter.js.map +1 -1
- package/dist/parser/index.d.ts +1 -1
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/parser/index.js +1 -1
- package/dist/parser/index.js.map +1 -1
- package/dist/parser/openapi-loader.d.ts.map +1 -1
- package/dist/parser/openapi-loader.js +36 -2
- package/dist/parser/openapi-loader.js.map +1 -1
- package/dist/parser/operation-extractor.d.ts.map +1 -1
- package/dist/parser/operation-extractor.js +12 -1
- package/dist/parser/operation-extractor.js.map +1 -1
- package/dist/parser/overlay-loader.d.ts.map +1 -1
- package/dist/parser/overlay-loader.js +11 -2
- package/dist/parser/overlay-loader.js.map +1 -1
- package/dist/parser/postman-loader.d.ts.map +1 -1
- package/dist/parser/postman-loader.js +6 -1
- package/dist/parser/postman-loader.js.map +1 -1
- package/dist/parser/schema-converter.d.ts +13 -2
- package/dist/parser/schema-converter.d.ts.map +1 -1
- package/dist/parser/schema-converter.js +64 -42
- package/dist/parser/schema-converter.js.map +1 -1
- package/dist/plugins/loader.d.ts +5 -1
- package/dist/plugins/loader.d.ts.map +1 -1
- package/dist/plugins/loader.js +39 -10
- package/dist/plugins/loader.js.map +1 -1
- package/dist/pricing.d.ts +5 -1
- package/dist/pricing.d.ts.map +1 -1
- package/dist/pricing.js +7 -3
- package/dist/pricing.js.map +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +6 -21
- package/dist/providers/index.js.map +1 -1
- package/dist/recorder/browser-recorder.d.ts.map +1 -1
- package/dist/recorder/browser-recorder.js +89 -12
- package/dist/recorder/browser-recorder.js.map +1 -1
- package/dist/rescan/rescan-scheduler.d.ts.map +1 -1
- package/dist/rescan/rescan-scheduler.js +22 -7
- package/dist/rescan/rescan-scheduler.js.map +1 -1
- package/dist/site-transformer/selector-healer.d.ts +1 -1
- package/dist/site-transformer/selector-healer.d.ts.map +1 -1
- package/dist/site-transformer/selector-healer.js +17 -18
- package/dist/site-transformer/selector-healer.js.map +1 -1
- package/dist/site-transformer/tool-generator.d.ts.map +1 -1
- package/dist/site-transformer/tool-generator.js +90 -22
- package/dist/site-transformer/tool-generator.js.map +1 -1
- package/dist/transformer/auth-detector.d.ts.map +1 -1
- package/dist/transformer/auth-detector.js +24 -5
- package/dist/transformer/auth-detector.js.map +1 -1
- package/dist/transformer/har-schema-inferrer.d.ts.map +1 -1
- package/dist/transformer/har-schema-inferrer.js +2 -0
- package/dist/transformer/har-schema-inferrer.js.map +1 -1
- package/dist/transformer/har-to-operations.d.ts +1 -0
- package/dist/transformer/har-to-operations.d.ts.map +1 -1
- package/dist/transformer/har-to-operations.js +42 -10
- package/dist/transformer/har-to-operations.js.map +1 -1
- package/dist/transformer/llm-namer.d.ts.map +1 -1
- package/dist/transformer/llm-namer.js +10 -13
- package/dist/transformer/llm-namer.js.map +1 -1
- package/dist/transformer/naming.d.ts.map +1 -1
- package/dist/transformer/naming.js +16 -8
- package/dist/transformer/naming.js.map +1 -1
- package/dist/transformer/resource-builder.d.ts.map +1 -1
- package/dist/transformer/resource-builder.js +13 -6
- package/dist/transformer/resource-builder.js.map +1 -1
- package/dist/transformer/stainless-translator.d.ts +6 -0
- package/dist/transformer/stainless-translator.d.ts.map +1 -1
- package/dist/transformer/stainless-translator.js +18 -1
- package/dist/transformer/stainless-translator.js.map +1 -1
- package/dist/transformer/tool-builder.d.ts.map +1 -1
- package/dist/transformer/tool-builder.js +141 -14
- package/dist/transformer/tool-builder.js.map +1 -1
- package/dist/types/index.d.ts +79 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/site.d.ts +19 -1
- package/dist/types/site.d.ts.map +1 -1
- package/dist/utils/fail.d.ts.map +1 -1
- package/dist/utils/fail.js +22 -4
- package/dist/utils/fail.js.map +1 -1
- package/dist/utils/json-extract.d.ts +21 -0
- package/dist/utils/json-extract.d.ts.map +1 -0
- package/dist/utils/json-extract.js +67 -0
- package/dist/utils/json-extract.js.map +1 -0
- package/dist/utils/model-resolver.d.ts.map +1 -1
- package/dist/utils/model-resolver.js +6 -0
- package/dist/utils/model-resolver.js.map +1 -1
- package/dist/utils/sanitize.d.ts +63 -1
- package/dist/utils/sanitize.d.ts.map +1 -1
- package/dist/utils/sanitize.js +147 -2
- package/dist/utils/sanitize.js.map +1 -1
- package/dist/utils/ssrf-guard.d.ts +10 -0
- package/dist/utils/ssrf-guard.d.ts.map +1 -0
- package/dist/utils/ssrf-guard.js +130 -0
- package/dist/utils/ssrf-guard.js.map +1 -0
- package/dist/utils/watcher.d.ts +17 -1
- package/dist/utils/watcher.d.ts.map +1 -1
- package/dist/utils/watcher.js +53 -21
- package/dist/utils/watcher.js.map +1 -1
- package/package.json +2 -1
|
@@ -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(
|
|
25
|
+
export async function getAuthHeaders(
|
|
26
|
+
config: AppConfig,
|
|
27
|
+
requirement?: AuthRequirement,
|
|
28
|
+
): Promise<Record<string, string>> {
|
|
8
29
|
{{else}}
|
|
9
|
-
export function getAuthHeaders(
|
|
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
|
|
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 —
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
-
|
|
92
|
-
|
|
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:
|
|
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
|
-
|
|
291
|
+
try {
|
|
292
|
+
return JSON.parse(text);
|
|
293
|
+
} catch {
|
|
294
|
+
return text;
|
|
295
|
+
}
|
|
153
296
|
}
|
|
154
|
-
return
|
|
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
|
-
*
|
|
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
|
}
|