@uteamup/mcp-server 1.0.0-beta.8 → 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/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
- const errors = error.errors.map(err => {
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(` API Key: ${config.apiKey.substring(0, 8)}...`);
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
  },
@@ -56,7 +92,8 @@ const pinoOptions = {
56
92
  colorize: true,
57
93
  translateTime: 'HH:MM:ss.l',
58
94
  ignore: 'pid,hostname',
59
- messageFormat: '[MCP Proxy] {msg}'
95
+ messageFormat: '[MCP Proxy] {msg}',
96
+ destination: 2 // Write to stderr (critical for MCP stdio protocol)
60
97
  }
61
98
  }
62
99
  };
@@ -79,9 +116,25 @@ function redactSensitive(data) {
79
116
  'code_verifier',
80
117
  'access_token',
81
118
  'accesstoken',
119
+ 'apikey',
120
+ 'api_key',
121
+ 'cookie',
122
+ 'refreshtoken',
123
+ 'refresh_token',
124
+ 'idtoken',
125
+ 'id_token',
126
+ 'oauthredirect',
82
127
  'bearer',
83
128
  'password',
84
- 'secret'
129
+ 'secret',
130
+ 'numericcode',
131
+ 'numeric_code',
132
+ 'photo',
133
+ 'bytes',
134
+ 'blob',
135
+ 'gps',
136
+ 'latitude',
137
+ 'longitude'
85
138
  ];
86
139
 
87
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`, { error: err.message });
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`, { error: err.message });
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.message,
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
+ };