@deotio/mcp-sigv4-proxy 0.3.0 → 0.4.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 (3) hide show
  1. package/README.md +5 -3
  2. package/dist/proxy.js +185 -2
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -32,7 +32,8 @@ stdin (JSON-RPC) -> validate -> SigV4 sign -> HTTPS POST -> response relay -> st
32
32
  2. Validates each message is well-formed JSON-RPC 2.0
33
33
  3. Signs the request with AWS SigV4 using the configured credentials
34
34
  4. Forwards to the target MCP endpoint via HTTPS (with configurable timeout and retries)
35
- 5. Relays the response (JSON or SSE stream) back to stdout
35
+ 5. Retries on HTTP 5xx and 424 (AgentCore cold-start timeout) with exponential backoff
36
+ 6. Relays the response (JSON or SSE stream) back to stdout
36
37
 
37
38
  ## Environment variables
38
39
 
@@ -43,8 +44,9 @@ stdin (JSON-RPC) -> validate -> SigV4 sign -> HTTPS POST -> response relay -> st
43
44
  | `AWS_REGION` | no | inferred from URL, then `us-east-1` | AWS region for SigV4 signing |
44
45
  | `AWS_SERVICE` | no | inferred from URL, then `bedrock-agentcore` | SigV4 service name |
45
46
  | `MCP_TIMEOUT` | no | `180` | Request timeout in seconds |
46
- | `MCP_RETRIES` | no | `0` | Retry count for 5xx errors and network failures (0-10) |
47
+ | `MCP_RETRIES` | no | `2` | Retry count for 5xx/424 errors and network failures (0-10) |
47
48
  | `MCP_LOG_LEVEL` | no | `ERROR` | Log verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `SILENT` |
49
+ | `MCP_WARM` | no | `0` | Set to `1` to enable [warm mode](doc/configuration.md#warm-mode) for slow-starting backends |
48
50
 
49
51
  ## Prerequisites
50
52
 
@@ -59,7 +61,7 @@ bedrock-agentcore:InvokeAgentRuntime
59
61
  - **HTTPS-only** — `MCP_SERVER_URL` must use `https://`. The only exception is `http://localhost` / `http://127.0.0.1` for local development.
60
62
  - **TLS enforcement** — the proxy refuses to start if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set, since it sends signed AWS credentials.
61
63
  - **Input validation** — only well-formed JSON-RPC 2.0 messages are signed and forwarded.
62
- - **Sanitized errors** — HTTP error bodies from the upstream are logged to stderr, not forwarded to the MCP client. Only the status code is relayed.
64
+ - **Informative errors** — HTTP error responses include the upstream error message (e.g. `HTTP 403: User is not authorized`) in the JSON-RPC error for easier debugging. Full response bodies are logged to stderr.
63
65
  - **Buffer limits** — SSE streams are capped at 1 MB to prevent unbounded memory growth.
64
66
 
65
67
  ## Documentation
package/dist/proxy.js CHANGED
@@ -8,6 +8,10 @@ const DEFAULT_TIMEOUT_MS = 180_000; // 180s, matches AWS proxy
8
8
  const DEFAULT_RETRIES = 2;
9
9
  const RETRY_BASE_MS = 1000;
10
10
  const COLD_START_RETRY_MS = 5000;
11
+ // Warm mode defaults
12
+ const DEFAULT_WARM_RETRIES = 5;
13
+ const DEFAULT_WARM_RETRY_DELAY_MS = 10_000;
14
+ const DEFAULT_WARM_TIMEOUT_MS = 300_000; // 5 min
11
15
  const LOG_LEVEL_ORDER = {
12
16
  DEBUG: 0,
13
17
  INFO: 1,
@@ -74,12 +78,26 @@ export function validateEnv() {
74
78
  const retries = process.env.MCP_RETRIES
75
79
  ? Math.min(Math.max(0, Math.floor(Number(process.env.MCP_RETRIES))), 10)
76
80
  : DEFAULT_RETRIES;
81
+ // Parse warm mode
82
+ const warm = process.env.MCP_WARM === '1';
83
+ const warmRetries = process.env.MCP_WARM_RETRIES
84
+ ? Math.min(Math.max(0, Math.floor(Number(process.env.MCP_WARM_RETRIES))), 20)
85
+ : DEFAULT_WARM_RETRIES;
86
+ const warmRetryDelayMs = process.env.MCP_WARM_RETRY_DELAY
87
+ ? Math.max(1000, Number(process.env.MCP_WARM_RETRY_DELAY))
88
+ : DEFAULT_WARM_RETRY_DELAY_MS;
89
+ const warmTimeoutMs = process.env.MCP_WARM_TIMEOUT
90
+ ? Number(process.env.MCP_WARM_TIMEOUT)
91
+ : DEFAULT_WARM_TIMEOUT_MS;
77
92
  log('INFO', `target: ${url.hostname}, service: ${service}, region: ${region}`);
78
93
  if (inferred) {
79
94
  log('DEBUG', `inferred service=${inferred.service}, region=${inferred.region} from URL`);
80
95
  }
81
96
  log('DEBUG', `timeout: ${timeoutMs}ms, retries: ${retries}`);
82
- return { url, region, service, timeoutMs, retries };
97
+ if (warm) {
98
+ log('INFO', `warm mode enabled (retries: ${warmRetries}, delay: ${warmRetryDelayMs}ms, timeout: ${warmTimeoutMs}ms)`);
99
+ }
100
+ return { url, region, service, timeoutMs, retries, warm, warmRetries, warmRetryDelayMs, warmTimeoutMs };
83
101
  }
84
102
  export function createSigner(config) {
85
103
  return new SignatureV4({
@@ -273,14 +291,179 @@ export async function processLine(line, config, signer) {
273
291
  }) + '\n');
274
292
  }
275
293
  }
294
+ const WARM_CACHEABLE = new Set([
295
+ 'initialize', 'tools/list', 'resources/list', 'prompts/list',
296
+ ]);
297
+ /**
298
+ * Synthetic MCP initialize response returned when the backend hasn't responded yet.
299
+ * Advertises tools/resources/prompts capabilities so Claude Code proceeds to list calls.
300
+ */
301
+ function syntheticInitializeResult() {
302
+ return {
303
+ protocolVersion: '2025-03-26',
304
+ capabilities: { tools: {}, resources: {}, prompts: {} },
305
+ serverInfo: { name: 'mcp-sigv4-proxy-warm', version: '0.4.0' },
306
+ };
307
+ }
308
+ export async function warmBackend(config, signer) {
309
+ const state = { ready: Promise.resolve(false), cache: {}, active: false };
310
+ state.ready = (async () => {
311
+ const deadline = Date.now() + config.warmTimeoutMs;
312
+ // Step 1: Initialize the backend (retry through cold-start 424s)
313
+ const initBody = JSON.stringify({
314
+ jsonrpc: '2.0',
315
+ id: '__warmup_init__',
316
+ method: 'initialize',
317
+ params: {
318
+ protocolVersion: '2025-03-26',
319
+ capabilities: {},
320
+ clientInfo: { name: 'mcp-sigv4-proxy', version: '0.4.0' },
321
+ },
322
+ });
323
+ let initResponse = null;
324
+ for (let attempt = 0; attempt <= config.warmRetries; attempt++) {
325
+ if (Date.now() >= deadline)
326
+ break;
327
+ try {
328
+ const request = buildHttpRequest(config.url, initBody);
329
+ const signed = await signer.sign(request);
330
+ initResponse = await fetchWithTimeout(config.url.toString(), { method: 'POST', headers: signed.headers, body: initBody }, Math.min(config.timeoutMs, deadline - Date.now()));
331
+ if (initResponse.ok)
332
+ break;
333
+ if (initResponse.status === 424 && attempt < config.warmRetries) {
334
+ const delay = config.warmRetryDelayMs * Math.pow(2, attempt);
335
+ log('INFO', `warm: cold-start (HTTP 424), retrying in ${delay}ms (${attempt + 1}/${config.warmRetries})`);
336
+ await sleep(Math.min(delay, deadline - Date.now()));
337
+ initResponse = null;
338
+ continue;
339
+ }
340
+ log('WARNING', `warm: initialize failed with HTTP ${initResponse.status}`);
341
+ return false;
342
+ }
343
+ catch (err) {
344
+ if (attempt < config.warmRetries) {
345
+ const delay = config.warmRetryDelayMs * Math.pow(2, attempt);
346
+ log('WARNING', `warm: initialize error (${err}), retrying in ${delay}ms`);
347
+ await sleep(Math.min(delay, deadline - Date.now()));
348
+ continue;
349
+ }
350
+ log('ERROR', `warm: initialize failed after ${config.warmRetries} retries: ${err}`);
351
+ return false;
352
+ }
353
+ }
354
+ if (!initResponse?.ok) {
355
+ log('ERROR', 'warm: backend did not respond to initialize within timeout');
356
+ return false;
357
+ }
358
+ // Check for session ID — warm mode is incompatible with stateful servers
359
+ const sessionId = initResponse.headers.get('mcp-session-id');
360
+ if (sessionId) {
361
+ log('WARNING', 'warm: backend returned mcp-session-id header — stateful servers are not '
362
+ + 'compatible with warm mode. Falling back to pass-through mode.');
363
+ return false;
364
+ }
365
+ // Cache the initialize result (extract from JSON-RPC response wrapper)
366
+ try {
367
+ const body = await initResponse.text();
368
+ const parsed = JSON.parse(body);
369
+ if (parsed.result) {
370
+ state.cache.initialize = parsed.result;
371
+ }
372
+ else {
373
+ state.cache.initialize = syntheticInitializeResult();
374
+ }
375
+ }
376
+ catch {
377
+ state.cache.initialize = syntheticInitializeResult();
378
+ }
379
+ log('INFO', 'warm: backend initialized, prefetching capability lists');
380
+ // Step 2: Prefetch tools/list, resources/list, prompts/list
381
+ const listMethods = ['tools/list', 'resources/list', 'prompts/list'];
382
+ await Promise.all(listMethods.map(async (method) => {
383
+ try {
384
+ const listBody = JSON.stringify({
385
+ jsonrpc: '2.0', id: `__warmup_${method}__`, method, params: {},
386
+ });
387
+ const request = buildHttpRequest(config.url, listBody);
388
+ const signed = await signer.sign(request);
389
+ const response = await fetchWithTimeout(config.url.toString(), { method: 'POST', headers: signed.headers, body: listBody }, Math.min(config.timeoutMs, Math.max(1000, deadline - Date.now())));
390
+ if (response.ok) {
391
+ const body = await response.text();
392
+ const parsed = JSON.parse(body);
393
+ if (parsed.result) {
394
+ state.cache[method] = parsed.result;
395
+ log('DEBUG', `warm: cached ${method} (${JSON.stringify(parsed.result).length} bytes)`);
396
+ }
397
+ }
398
+ }
399
+ catch (err) {
400
+ log('WARNING', `warm: failed to prefetch ${method}: ${err}`);
401
+ }
402
+ }));
403
+ state.active = true;
404
+ const cached = Object.keys(state.cache).length;
405
+ log('INFO', `warm: ready (${cached} responses cached)`);
406
+ return true;
407
+ })();
408
+ return state;
409
+ }
410
+ /**
411
+ * Process a line in warm mode. Returns true if the message was handled locally
412
+ * (from cache or synthetic response), false if it should be forwarded to the backend.
413
+ */
414
+ export function tryWarmResponse(body, requestId, warmState) {
415
+ let parsed;
416
+ try {
417
+ parsed = JSON.parse(body);
418
+ }
419
+ catch {
420
+ return false;
421
+ }
422
+ const method = parsed.method;
423
+ if (!method || !WARM_CACHEABLE.has(method))
424
+ return false;
425
+ const cached = warmState.cache[method];
426
+ if (!cached)
427
+ return false;
428
+ // Respond from cache
429
+ const response = JSON.stringify({
430
+ jsonrpc: '2.0',
431
+ id: requestId,
432
+ result: cached,
433
+ });
434
+ process.stdout.write(response + '\n');
435
+ log('DEBUG', `warm: served ${method} from cache`);
436
+ // For initialize, also send the initialized notification to the backend (fire-and-forget).
437
+ // Claude Code sends this too, but in warm mode we intercepted initialize so we handle it.
438
+ return true;
439
+ }
276
440
  // --- Main entry ---
277
441
  export function startProxy() {
278
442
  const config = validateEnv();
279
443
  const signer = createSigner(config);
280
444
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
445
+ // Start warm-up in background if enabled (non-blocking)
446
+ let warmState = null;
447
+ if (config.warm) {
448
+ warmBackend(config, signer).then((state) => { warmState = state; });
449
+ }
281
450
  let pending = Promise.resolve();
282
451
  rl.on('line', (line) => {
283
- pending = pending.then(() => processLine(line, config, signer)).catch(() => { });
452
+ pending = pending.then(async () => {
453
+ // In warm mode, try to serve from cache first
454
+ if (warmState) {
455
+ const input = parseInputLine(line);
456
+ if (input) {
457
+ // Wait for warm-up to finish before deciding
458
+ const warmActive = await warmState.ready;
459
+ if (warmActive && tryWarmResponse(input.body, input.requestId, warmState)) {
460
+ return; // served from cache
461
+ }
462
+ // Fall through: warm mode inactive or method not cacheable — forward normally
463
+ }
464
+ }
465
+ await processLine(line, config, signer);
466
+ }).catch(() => { });
284
467
  });
285
468
  rl.on('close', async () => {
286
469
  log('INFO', 'stdin closed, draining in-flight requests');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deotio/mcp-sigv4-proxy",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "stdio MCP proxy with AWS SigV4 signing — connect Claude Code to any IAM-authenticated MCP server using a named AWS profile",
5
5
  "type": "module",
6
6
  "bin": {