@uteamup/mcp-server 1.0.0-beta.9 → 2.3.0-beta.9
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/CHANGELOG.md +631 -0
- package/README.md +77 -0
- package/assets/logo.png +0 -0
- package/assets/logo.svg +11 -0
- package/index.js +424 -203
- package/lib/config.js +60 -5
- package/lib/logger.js +54 -2
- package/lib/mcp-http-headers.js +87 -0
- package/lib/retry.js +12 -3
- package/lib/transport-security.js +252 -0
- package/openapi-actions.json +234 -0
- package/package.json +27 -13
- package/server.json +77 -0
package/lib/config.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
|
+
const { normalizeBackendBaseUrl } = require('./transport-security');
|
|
7
8
|
|
|
8
9
|
// Configuration schema
|
|
9
10
|
const configSchema = z.object({
|
|
@@ -19,10 +20,25 @@ const configSchema = z.object({
|
|
|
19
20
|
// Optional configuration
|
|
20
21
|
baseUrl: z.string()
|
|
21
22
|
.url('Base URL must be a valid URL')
|
|
23
|
+
.transform((value, context) => {
|
|
24
|
+
try {
|
|
25
|
+
return normalizeBackendBaseUrl(value);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
context.addIssue({
|
|
28
|
+
code: 'custom',
|
|
29
|
+
message: error.message
|
|
30
|
+
});
|
|
31
|
+
return z.NEVER;
|
|
32
|
+
}
|
|
33
|
+
})
|
|
22
34
|
.default('https://api.uteamup.com')
|
|
23
35
|
.describe('API endpoint URL'),
|
|
24
36
|
|
|
25
37
|
// Logging configuration
|
|
38
|
+
mcpPath: z.enum(['/mcp', '/mcp/read-only'])
|
|
39
|
+
.default('/mcp')
|
|
40
|
+
.describe('MCP transport path; the read-only pilot is enforced by the backend'),
|
|
41
|
+
|
|
26
42
|
logLevel: z.enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'])
|
|
27
43
|
.default('INFO')
|
|
28
44
|
.describe('Logging level'),
|
|
@@ -51,6 +67,20 @@ const configSchema = z.object({
|
|
|
51
67
|
.default(3)
|
|
52
68
|
.describe('Maximum retry attempts for network errors'),
|
|
53
69
|
|
|
70
|
+
maxRequestBytes: z.number()
|
|
71
|
+
.int()
|
|
72
|
+
.min(1024)
|
|
73
|
+
.max(10485760)
|
|
74
|
+
.default(1048576)
|
|
75
|
+
.describe('Maximum size of one JSON-RPC request'),
|
|
76
|
+
|
|
77
|
+
maxResponseBytes: z.number()
|
|
78
|
+
.int()
|
|
79
|
+
.min(1024)
|
|
80
|
+
.max(104857600)
|
|
81
|
+
.default(10485760)
|
|
82
|
+
.describe('Maximum size of one backend response'),
|
|
83
|
+
|
|
54
84
|
// Token management
|
|
55
85
|
tokenCacheDir: z.string()
|
|
56
86
|
.optional()
|
|
@@ -61,7 +91,22 @@ const configSchema = z.object({
|
|
|
61
91
|
.min(0)
|
|
62
92
|
.max(3600) // 1 hour max
|
|
63
93
|
.default(300) // 5 minutes
|
|
64
|
-
.describe('Token refresh margin in seconds')
|
|
94
|
+
.describe('Token refresh margin in seconds'),
|
|
95
|
+
|
|
96
|
+
// UTEAMUP-SEC-2026-05-02-008: explicit opt-in to disable TLS verification
|
|
97
|
+
// for self-signed dev certs. Without this flag the MCP proxy validates
|
|
98
|
+
// TLS like any other production HTTPS client. Defense-in-depth: even when
|
|
99
|
+
// set, the flag is ignored for the production host (api.uteamup.com).
|
|
100
|
+
insecureTLS: z.boolean()
|
|
101
|
+
.default(false)
|
|
102
|
+
.describe('Disable TLS certificate verification (dev only — never set against api.uteamup.com)')
|
|
103
|
+
}).transform(config => {
|
|
104
|
+
// Apply the production policy after URL parsing has canonicalized the host.
|
|
105
|
+
const hostname = new URL(config.baseUrl).hostname.replace(/\.$/, '');
|
|
106
|
+
if (hostname === 'api.uteamup.com') {
|
|
107
|
+
config.insecureTLS = false;
|
|
108
|
+
}
|
|
109
|
+
return config;
|
|
65
110
|
});
|
|
66
111
|
|
|
67
112
|
/**
|
|
@@ -75,13 +120,17 @@ function loadConfig() {
|
|
|
75
120
|
apiKey: process.env.UTEAMUP_API_KEY,
|
|
76
121
|
secret: process.env.UTEAMUP_SECRET,
|
|
77
122
|
baseUrl: process.env.UTEAMUP_API_BASE_URL || 'https://api.uteamup.com',
|
|
123
|
+
mcpPath: process.env.UTEAMUP_MCP_PATH || '/mcp',
|
|
78
124
|
logLevel: (process.env.UTEAMUP_LOG_LEVEL || 'INFO').toUpperCase(),
|
|
79
125
|
logFormat: (process.env.UTEAMUP_LOG_FORMAT || 'text').toLowerCase(),
|
|
80
126
|
debug: process.env.UTEAMUP_DEBUG === '1' || process.env.UTEAMUP_DEBUG === 'true',
|
|
81
127
|
requestTimeout: parseInt(process.env.UTEAMUP_REQUEST_TIMEOUT || '30000', 10),
|
|
82
128
|
maxRetries: parseInt(process.env.UTEAMUP_MAX_RETRIES || '3', 10),
|
|
129
|
+
maxRequestBytes: parseInt(process.env.UTEAMUP_MAX_REQUEST_BYTES || '1048576', 10),
|
|
130
|
+
maxResponseBytes: parseInt(process.env.UTEAMUP_MAX_RESPONSE_BYTES || '10485760', 10),
|
|
83
131
|
tokenCacheDir: process.env.UTEAMUP_TOKEN_CACHE_DIR,
|
|
84
|
-
tokenRefreshMargin: parseInt(process.env.UTEAMUP_TOKEN_REFRESH_MARGIN || '300', 10)
|
|
132
|
+
tokenRefreshMargin: parseInt(process.env.UTEAMUP_TOKEN_REFRESH_MARGIN || '300', 10),
|
|
133
|
+
insecureTLS: process.env.UTEAMUP_INSECURE_TLS === '1' || process.env.UTEAMUP_INSECURE_TLS === 'true'
|
|
85
134
|
};
|
|
86
135
|
|
|
87
136
|
// If debug mode is enabled, upgrade log level to DEBUG
|
|
@@ -95,8 +144,9 @@ function loadConfig() {
|
|
|
95
144
|
return config;
|
|
96
145
|
} catch (error) {
|
|
97
146
|
if (error instanceof z.ZodError) {
|
|
98
|
-
// Format validation errors nicely
|
|
99
|
-
|
|
147
|
+
// Format validation errors nicely.
|
|
148
|
+
// zod 4 renamed `error.errors` -> `error.issues`.
|
|
149
|
+
const errors = error.issues.map(err => {
|
|
100
150
|
const path = err.path.join('.');
|
|
101
151
|
return ` - ${path}: ${err.message}`;
|
|
102
152
|
});
|
|
@@ -109,11 +159,14 @@ function loadConfig() {
|
|
|
109
159
|
' UTEAMUP_SECRET - Your API key secret (64+ characters)\n\n' +
|
|
110
160
|
'Optional environment variables:\n' +
|
|
111
161
|
' UTEAMUP_API_BASE_URL - API endpoint (default: https://api.uteamup.com)\n' +
|
|
162
|
+
' UTEAMUP_MCP_PATH - /mcp or /mcp/read-only (default: /mcp)\n' +
|
|
112
163
|
' UTEAMUP_LOG_LEVEL - Log level (default: INFO)\n' +
|
|
113
164
|
' UTEAMUP_LOG_FORMAT - Log format (default: text)\n' +
|
|
114
165
|
' UTEAMUP_DEBUG - Enable debug mode (1 or true)\n' +
|
|
115
166
|
' UTEAMUP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000)\n' +
|
|
116
167
|
' UTEAMUP_MAX_RETRIES - Max retry attempts (default: 3)\n' +
|
|
168
|
+
' UTEAMUP_MAX_REQUEST_BYTES - Max JSON-RPC request bytes (default: 1048576)\n' +
|
|
169
|
+
' UTEAMUP_MAX_RESPONSE_BYTES - Max backend response bytes (default: 10485760)\n' +
|
|
117
170
|
' UTEAMUP_TOKEN_CACHE_DIR - Persistent token cache directory\n' +
|
|
118
171
|
' UTEAMUP_TOKEN_REFRESH_MARGIN - Token refresh margin in seconds (default: 300)'
|
|
119
172
|
);
|
|
@@ -142,11 +195,13 @@ function validateConfig() {
|
|
|
142
195
|
function printConfigSummary(config) {
|
|
143
196
|
console.error('[MCP Proxy] Configuration:');
|
|
144
197
|
console.error(` Base URL: ${config.baseUrl}`);
|
|
145
|
-
console.error(
|
|
198
|
+
console.error(' API Key: configured');
|
|
146
199
|
console.error(` Log Level: ${config.logLevel}`);
|
|
147
200
|
console.error(` Log Format: ${config.logFormat}`);
|
|
148
201
|
console.error(` Request Timeout: ${config.requestTimeout}ms`);
|
|
149
202
|
console.error(` Max Retries: ${config.maxRetries}`);
|
|
203
|
+
console.error(` Max Request Bytes: ${config.maxRequestBytes}`);
|
|
204
|
+
console.error(` Max Response Bytes: ${config.maxResponseBytes}`);
|
|
150
205
|
console.error(` Token Refresh Margin: ${config.tokenRefreshMargin}s`);
|
|
151
206
|
if (config.tokenCacheDir) {
|
|
152
207
|
console.error(` Token Cache Dir: ${config.tokenCacheDir}`);
|
package/lib/logger.js
CHANGED
|
@@ -37,12 +37,48 @@ const pinoOptions = {
|
|
|
37
37
|
'code_verifier',
|
|
38
38
|
'access_token',
|
|
39
39
|
'accessToken',
|
|
40
|
+
'apiKey',
|
|
41
|
+
'api_key',
|
|
42
|
+
'cookie',
|
|
43
|
+
'setCookie',
|
|
44
|
+
'set-cookie',
|
|
45
|
+
'refreshToken',
|
|
46
|
+
'refresh_token',
|
|
47
|
+
'idToken',
|
|
48
|
+
'id_token',
|
|
49
|
+
'oauthRedirect',
|
|
40
50
|
'bearer',
|
|
41
51
|
'*.Authorization',
|
|
42
52
|
'*.client_secret',
|
|
43
53
|
'*.code_verifier',
|
|
44
54
|
'*.access_token',
|
|
45
|
-
'*.accessToken'
|
|
55
|
+
'*.accessToken',
|
|
56
|
+
'*.apiKey',
|
|
57
|
+
'*.api_key',
|
|
58
|
+
'*.cookie',
|
|
59
|
+
'*.setCookie',
|
|
60
|
+
'*.set-cookie',
|
|
61
|
+
'*.refreshToken',
|
|
62
|
+
'*.refresh_token',
|
|
63
|
+
'*.idToken',
|
|
64
|
+
'*.id_token',
|
|
65
|
+
'*.oauthRedirect',
|
|
66
|
+
'numericCode',
|
|
67
|
+
'numeric_code',
|
|
68
|
+
'*.numericCode',
|
|
69
|
+
'*.numeric_code',
|
|
70
|
+
'photo',
|
|
71
|
+
'bytes',
|
|
72
|
+
'blob',
|
|
73
|
+
'gps',
|
|
74
|
+
'latitude',
|
|
75
|
+
'longitude',
|
|
76
|
+
'*.photo',
|
|
77
|
+
'*.bytes',
|
|
78
|
+
'*.blob',
|
|
79
|
+
'*.gps',
|
|
80
|
+
'*.latitude',
|
|
81
|
+
'*.longitude'
|
|
46
82
|
],
|
|
47
83
|
remove: true
|
|
48
84
|
},
|
|
@@ -80,9 +116,25 @@ function redactSensitive(data) {
|
|
|
80
116
|
'code_verifier',
|
|
81
117
|
'access_token',
|
|
82
118
|
'accesstoken',
|
|
119
|
+
'apikey',
|
|
120
|
+
'api_key',
|
|
121
|
+
'cookie',
|
|
122
|
+
'refreshtoken',
|
|
123
|
+
'refresh_token',
|
|
124
|
+
'idtoken',
|
|
125
|
+
'id_token',
|
|
126
|
+
'oauthredirect',
|
|
83
127
|
'bearer',
|
|
84
128
|
'password',
|
|
85
|
-
'secret'
|
|
129
|
+
'secret',
|
|
130
|
+
'numericcode',
|
|
131
|
+
'numeric_code',
|
|
132
|
+
'photo',
|
|
133
|
+
'bytes',
|
|
134
|
+
'blob',
|
|
135
|
+
'gps',
|
|
136
|
+
'latitude',
|
|
137
|
+
'longitude'
|
|
86
138
|
];
|
|
87
139
|
|
|
88
140
|
const redacted = Array.isArray(data) ? [...data] : { ...data };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Streamable HTTP headers mirrored from a JSON-RPC MCP message (SEP-2243).
|
|
5
|
+
*
|
|
6
|
+
* The C# SDK 2.x backend requires these on protocol 2026-07-28+. Down-level
|
|
7
|
+
* initialize-handshake clients omit protocol version in the body; we omit
|
|
8
|
+
* MCP-Protocol-Version in that case so SDK 2.x keeps its back-compat path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion';
|
|
12
|
+
const NAMED_METHODS = new Set(['tools/call', 'prompts/get', 'resources/read']);
|
|
13
|
+
|
|
14
|
+
function readProtocolVersion(request) {
|
|
15
|
+
const params = request && typeof request.params === 'object' && request.params !== null
|
|
16
|
+
? request.params
|
|
17
|
+
: null;
|
|
18
|
+
if (!params) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const meta = params._meta;
|
|
23
|
+
if (meta && typeof meta === 'object' && typeof meta[PROTOCOL_VERSION_META_KEY] === 'string') {
|
|
24
|
+
const fromMeta = meta[PROTOCOL_VERSION_META_KEY].trim();
|
|
25
|
+
if (fromMeta) {
|
|
26
|
+
return fromMeta;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (request.method === 'initialize' && typeof params.protocolVersion === 'string') {
|
|
31
|
+
const fromInit = params.protocolVersion.trim();
|
|
32
|
+
if (fromInit) {
|
|
33
|
+
return fromInit;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readTargetName(request) {
|
|
41
|
+
if (!NAMED_METHODS.has(request.method)) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const params = request.params;
|
|
46
|
+
if (!params || typeof params !== 'object') {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const raw = request.method === 'resources/read' ? params.uri : params.name;
|
|
51
|
+
if (typeof raw !== 'string') {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const value = raw.trim();
|
|
56
|
+
return value.length > 0 ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} request JSON-RPC 2.0 MCP request or notification
|
|
61
|
+
* @returns {Record<string, string>}
|
|
62
|
+
*/
|
|
63
|
+
function buildMcpForwardHeaders(request) {
|
|
64
|
+
const headers = {};
|
|
65
|
+
if (!request || typeof request.method !== 'string' || request.method.length === 0) {
|
|
66
|
+
return headers;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
headers['Mcp-Method'] = request.method;
|
|
70
|
+
|
|
71
|
+
const version = readProtocolVersion(request);
|
|
72
|
+
if (version) {
|
|
73
|
+
headers['MCP-Protocol-Version'] = version;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const name = readTargetName(request);
|
|
77
|
+
if (name) {
|
|
78
|
+
headers['Mcp-Name'] = name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return headers;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
PROTOCOL_VERSION_META_KEY,
|
|
86
|
+
buildMcpForwardHeaders
|
|
87
|
+
};
|
package/lib/retry.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { warn, error } = require('./logger');
|
|
7
|
+
const { toPublicErrorMessage } = require('./transport-security');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Sleep utility
|
|
@@ -96,13 +97,21 @@ async function retryWithBackoff(fn, options = {}) {
|
|
|
96
97
|
|
|
97
98
|
// If this was the last attempt, throw the error
|
|
98
99
|
if (attempt > maxRetries) {
|
|
99
|
-
error(`${operationName} failed after ${maxRetries} retries`, {
|
|
100
|
+
error(`${operationName} failed after ${maxRetries} retries`, {
|
|
101
|
+
error: toPublicErrorMessage(err),
|
|
102
|
+
errorCode: err?.code,
|
|
103
|
+
statusCode: err?.statusCode
|
|
104
|
+
});
|
|
100
105
|
throw err;
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
// Check if error is retryable
|
|
104
109
|
if (!shouldRetry(err)) {
|
|
105
|
-
error(`${operationName} failed with non-retryable error`, {
|
|
110
|
+
error(`${operationName} failed with non-retryable error`, {
|
|
111
|
+
error: toPublicErrorMessage(err),
|
|
112
|
+
errorCode: err?.code,
|
|
113
|
+
statusCode: err?.statusCode
|
|
114
|
+
});
|
|
106
115
|
throw err;
|
|
107
116
|
}
|
|
108
117
|
|
|
@@ -110,7 +119,7 @@ async function retryWithBackoff(fn, options = {}) {
|
|
|
110
119
|
const delay = calculateBackoff(attempt, baseDelay, maxDelay);
|
|
111
120
|
|
|
112
121
|
warn(`${operationName} failed (attempt ${attempt}/${maxRetries + 1}), retrying in ${delay}ms`, {
|
|
113
|
-
error: err
|
|
122
|
+
error: toPublicErrorMessage(err),
|
|
114
123
|
attempt,
|
|
115
124
|
maxRetries,
|
|
116
125
|
delayMs: delay
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security helpers for the stdio-to-HTTPS transport boundary.
|
|
3
|
+
*
|
|
4
|
+
* Only ProxyTransportError messages are safe to return to an MCP client. All
|
|
5
|
+
* other errors may contain backend response bodies, local paths, or secrets.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
class ProxyTransportError extends Error {
|
|
9
|
+
constructor(message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'ProxyTransportError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const RETRY_SAFE_MCP_METHODS = new Set([
|
|
16
|
+
'ping',
|
|
17
|
+
'prompts/get',
|
|
18
|
+
'prompts/list',
|
|
19
|
+
'resources/list',
|
|
20
|
+
'resources/read',
|
|
21
|
+
'resources/templates/list',
|
|
22
|
+
'server/discover',
|
|
23
|
+
'tools/list'
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse the token response and require the backend-provided lifetime. Guessing
|
|
28
|
+
* a lifetime can leave an expired token cached long after the server rejects it.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} data
|
|
31
|
+
* @returns {{accessToken: string, expiresInSeconds: number}}
|
|
32
|
+
*/
|
|
33
|
+
function parseOAuthTokenResponse(data) {
|
|
34
|
+
let json;
|
|
35
|
+
try {
|
|
36
|
+
json = JSON.parse(data);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new ProxyTransportError('Token exchange returned invalid JSON');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (json === null || typeof json !== 'object' || Array.isArray(json)) {
|
|
42
|
+
throw new ProxyTransportError('Token exchange returned an invalid response');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const accessToken = json.accessToken || json.access_token;
|
|
46
|
+
const expiresInValue = json.expiresIn ?? json.expires_in;
|
|
47
|
+
const expiresInSeconds = Number(expiresInValue);
|
|
48
|
+
if (
|
|
49
|
+
typeof accessToken !== 'string' ||
|
|
50
|
+
accessToken.trim().length === 0 ||
|
|
51
|
+
!Number.isSafeInteger(expiresInSeconds) ||
|
|
52
|
+
expiresInSeconds <= 0
|
|
53
|
+
) {
|
|
54
|
+
throw new ProxyTransportError('Token exchange returned an invalid response');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { accessToken, expiresInSeconds };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Only retry protocol reads. `tools/call` may mutate business data, and the
|
|
62
|
+
* proxy cannot prove that every tool is idempotent after a lost response.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} method
|
|
65
|
+
* @param {number} configuredMaxRetries
|
|
66
|
+
* @returns {number}
|
|
67
|
+
*/
|
|
68
|
+
function getMcpRetryLimit(method, configuredMaxRetries) {
|
|
69
|
+
return RETRY_SAFE_MCP_METHODS.has(method) ? configuredMaxRetries : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Accept only a bare HTTPS origin for the configured backend.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} value
|
|
76
|
+
* @returns {string}
|
|
77
|
+
*/
|
|
78
|
+
function normalizeBackendBaseUrl(value) {
|
|
79
|
+
let url;
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(value);
|
|
83
|
+
} catch {
|
|
84
|
+
throw new ProxyTransportError('Base URL must be a valid HTTPS origin');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const hasUnexpectedPath = url.pathname !== '' && url.pathname !== '/';
|
|
88
|
+
if (
|
|
89
|
+
url.protocol !== 'https:' ||
|
|
90
|
+
!url.hostname ||
|
|
91
|
+
url.username ||
|
|
92
|
+
url.password ||
|
|
93
|
+
hasUnexpectedPath ||
|
|
94
|
+
url.search ||
|
|
95
|
+
url.hash
|
|
96
|
+
) {
|
|
97
|
+
throw new ProxyTransportError('Base URL must be a bare HTTPS origin without credentials, path, query, or fragment');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return url.origin;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Incrementally reads newline-delimited input without allowing one untrusted
|
|
105
|
+
* request to grow memory without bound.
|
|
106
|
+
*/
|
|
107
|
+
class BoundedLineBuffer {
|
|
108
|
+
constructor(maxBytes) {
|
|
109
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
|
110
|
+
throw new TypeError('maxBytes must be a positive safe integer');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
this.maxBytes = maxBytes;
|
|
114
|
+
this.pending = Buffer.alloc(0);
|
|
115
|
+
this.discardingOversizedLine = false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {string|Buffer} chunk
|
|
120
|
+
* @returns {Array<{line: string}|{oversized: true}>}
|
|
121
|
+
*/
|
|
122
|
+
push(chunk) {
|
|
123
|
+
let remaining = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, 'utf8');
|
|
124
|
+
const events = [];
|
|
125
|
+
|
|
126
|
+
while (remaining.length > 0) {
|
|
127
|
+
if (this.discardingOversizedLine) {
|
|
128
|
+
const newlineIndex = remaining.indexOf(0x0a);
|
|
129
|
+
if (newlineIndex === -1) {
|
|
130
|
+
return events;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
remaining = remaining.subarray(newlineIndex + 1);
|
|
134
|
+
this.discardingOversizedLine = false;
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const newlineIndex = remaining.indexOf(0x0a);
|
|
139
|
+
if (newlineIndex === -1) {
|
|
140
|
+
if (this.pending.length + remaining.length > this.maxBytes) {
|
|
141
|
+
this.pending = Buffer.alloc(0);
|
|
142
|
+
this.discardingOversizedLine = true;
|
|
143
|
+
events.push({ oversized: true });
|
|
144
|
+
} else {
|
|
145
|
+
this.pending = Buffer.concat([this.pending, remaining]);
|
|
146
|
+
}
|
|
147
|
+
return events;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const lineRemainder = remaining.subarray(0, newlineIndex);
|
|
151
|
+
const lineLength = this.pending.length + lineRemainder.length;
|
|
152
|
+
|
|
153
|
+
if (lineLength > this.maxBytes) {
|
|
154
|
+
events.push({ oversized: true });
|
|
155
|
+
} else {
|
|
156
|
+
let line = Buffer.concat([this.pending, lineRemainder]).toString('utf8');
|
|
157
|
+
if (line.endsWith('\r')) {
|
|
158
|
+
line = line.slice(0, -1);
|
|
159
|
+
}
|
|
160
|
+
events.push({ line });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.pending = Buffer.alloc(0);
|
|
164
|
+
remaining = remaining.subarray(newlineIndex + 1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return events;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Collect an HTTP response body up to a strict byte limit.
|
|
173
|
+
*
|
|
174
|
+
* @param {import('stream').Readable & {headers?: object}} response
|
|
175
|
+
* @param {number} maxBytes
|
|
176
|
+
* @param {string} label
|
|
177
|
+
* @returns {Promise<string>}
|
|
178
|
+
*/
|
|
179
|
+
function collectResponseBody(response, maxBytes, label) {
|
|
180
|
+
const declaredLength = Number(response.headers?.['content-length']);
|
|
181
|
+
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
182
|
+
response.resume?.();
|
|
183
|
+
return Promise.reject(new ProxyTransportError(`${label} exceeded the configured size limit`));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const chunks = [];
|
|
188
|
+
let totalBytes = 0;
|
|
189
|
+
let settled = false;
|
|
190
|
+
|
|
191
|
+
const rejectOnce = error => {
|
|
192
|
+
if (settled) return;
|
|
193
|
+
settled = true;
|
|
194
|
+
reject(error);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
response.on('data', chunk => {
|
|
198
|
+
if (settled) return;
|
|
199
|
+
|
|
200
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
201
|
+
totalBytes += buffer.length;
|
|
202
|
+
if (totalBytes > maxBytes) {
|
|
203
|
+
rejectOnce(new ProxyTransportError(`${label} exceeded the configured size limit`));
|
|
204
|
+
response.destroy?.();
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
chunks.push(buffer);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
response.once('end', () => {
|
|
212
|
+
if (settled) return;
|
|
213
|
+
settled = true;
|
|
214
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
215
|
+
});
|
|
216
|
+
response.once('aborted', () => rejectOnce(new ProxyTransportError(`${label} was interrupted`)));
|
|
217
|
+
response.once('error', rejectOnce);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Ensure socket timeouts terminate the underlying request.
|
|
223
|
+
*
|
|
224
|
+
* @param {import('stream').Writable} request
|
|
225
|
+
* @param {string} label
|
|
226
|
+
*/
|
|
227
|
+
function attachRequestTimeout(request, label) {
|
|
228
|
+
request.once('timeout', () => {
|
|
229
|
+
request.destroy(new ProxyTransportError(`${label} timed out`));
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {unknown} error
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
function toPublicErrorMessage(error) {
|
|
238
|
+
return error instanceof ProxyTransportError
|
|
239
|
+
? error.message
|
|
240
|
+
: 'The MCP request could not be completed';
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
BoundedLineBuffer,
|
|
245
|
+
ProxyTransportError,
|
|
246
|
+
attachRequestTimeout,
|
|
247
|
+
collectResponseBody,
|
|
248
|
+
getMcpRetryLimit,
|
|
249
|
+
normalizeBackendBaseUrl,
|
|
250
|
+
parseOAuthTokenResponse,
|
|
251
|
+
toPublicErrorMessage
|
|
252
|
+
};
|