@deotio/mcp-sigv4-proxy 0.2.1 → 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 +7 -4
  2. package/dist/proxy.js +208 -8
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,12 +14,13 @@ Add to your `.mcp.json`:
14
14
  "args": ["-y", "@deotio/mcp-sigv4-proxy"],
15
15
  "env": {
16
16
  "AWS_PROFILE": "dot-finops",
17
+ "AWS_REGION": "us-east-1",
17
18
  "MCP_SERVER_URL": "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/.../invocations?qualifier=DEFAULT"
18
19
  }
19
20
  }
20
21
  ```
21
22
 
22
- The service name (`bedrock-agentcore`) and region (`us-east-1`) are inferred from the URL automatically. Set `AWS_REGION` and `AWS_SERVICE` only when using non-standard endpoints.
23
+ Always set `AWS_REGION` explicitly the proxy can infer it from standard AWS hostnames, but `AWS_REGION` from your shell environment takes precedence and may point to a different region. `AWS_SERVICE` is inferred automatically and only needs to be set for non-standard endpoints.
23
24
 
24
25
  ## How it works
25
26
 
@@ -31,7 +32,8 @@ stdin (JSON-RPC) -> validate -> SigV4 sign -> HTTPS POST -> response relay -> st
31
32
  2. Validates each message is well-formed JSON-RPC 2.0
32
33
  3. Signs the request with AWS SigV4 using the configured credentials
33
34
  4. Forwards to the target MCP endpoint via HTTPS (with configurable timeout and retries)
34
- 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
35
37
 
36
38
  ## Environment variables
37
39
 
@@ -42,8 +44,9 @@ stdin (JSON-RPC) -> validate -> SigV4 sign -> HTTPS POST -> response relay -> st
42
44
  | `AWS_REGION` | no | inferred from URL, then `us-east-1` | AWS region for SigV4 signing |
43
45
  | `AWS_SERVICE` | no | inferred from URL, then `bedrock-agentcore` | SigV4 service name |
44
46
  | `MCP_TIMEOUT` | no | `180` | Request timeout in seconds |
45
- | `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) |
46
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 |
47
50
 
48
51
  ## Prerequisites
49
52
 
@@ -58,7 +61,7 @@ bedrock-agentcore:InvokeAgentRuntime
58
61
  - **HTTPS-only** — `MCP_SERVER_URL` must use `https://`. The only exception is `http://localhost` / `http://127.0.0.1` for local development.
59
62
  - **TLS enforcement** — the proxy refuses to start if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set, since it sends signed AWS credentials.
60
63
  - **Input validation** — only well-formed JSON-RPC 2.0 messages are signed and forwarded.
61
- - **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.
62
65
  - **Buffer limits** — SSE streams are capped at 1 MB to prevent unbounded memory growth.
63
66
 
64
67
  ## Documentation
package/dist/proxy.js CHANGED
@@ -5,8 +5,13 @@ import { Sha256 } from '@aws-crypto/sha256-js';
5
5
  import readline from 'readline';
6
6
  export const MAX_SSE_BUFFER_BYTES = 1_048_576; // 1 MB
7
7
  const DEFAULT_TIMEOUT_MS = 180_000; // 180s, matches AWS proxy
8
- const DEFAULT_RETRIES = 0;
8
+ const DEFAULT_RETRIES = 2;
9
9
  const RETRY_BASE_MS = 1000;
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
10
15
  const LOG_LEVEL_ORDER = {
11
16
  DEBUG: 0,
12
17
  INFO: 1,
@@ -73,12 +78,26 @@ export function validateEnv() {
73
78
  const retries = process.env.MCP_RETRIES
74
79
  ? Math.min(Math.max(0, Math.floor(Number(process.env.MCP_RETRIES))), 10)
75
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;
76
92
  log('INFO', `target: ${url.hostname}, service: ${service}, region: ${region}`);
77
93
  if (inferred) {
78
94
  log('DEBUG', `inferred service=${inferred.service}, region=${inferred.region} from URL`);
79
95
  }
80
96
  log('DEBUG', `timeout: ${timeoutMs}ms, retries: ${retries}`);
81
- 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 };
82
101
  }
83
102
  export function createSigner(config) {
84
103
  return new SignatureV4({
@@ -145,10 +164,14 @@ async function fetchWithRetry(url, init, timeoutMs, retries) {
145
164
  for (let attempt = 0; attempt <= retries; attempt++) {
146
165
  try {
147
166
  const response = await fetchWithTimeout(url, init, timeoutMs);
148
- // Only retry on 5xx server errors
149
- if (response.status >= 500 && attempt < retries) {
150
- log('WARNING', `HTTP ${response.status}, retrying (${attempt + 1}/${retries})`);
151
- await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
167
+ // Retry on 5xx server errors and 424 (AgentCore cold-start timeout)
168
+ const retryable = response.status >= 500 || response.status === 424;
169
+ if (retryable && attempt < retries) {
170
+ const delay = response.status === 424
171
+ ? COLD_START_RETRY_MS * Math.pow(2, attempt)
172
+ : RETRY_BASE_MS * Math.pow(2, attempt);
173
+ log('WARNING', `HTTP ${response.status}, retrying in ${delay}ms (${attempt + 1}/${retries})`);
174
+ await sleep(delay);
152
175
  continue;
153
176
  }
154
177
  return response;
@@ -172,10 +195,22 @@ export async function handleResponse(response, requestId) {
172
195
  if (!response.ok) {
173
196
  const text = await response.text();
174
197
  log('ERROR', `HTTP ${response.status}: ${text}`);
198
+ // Extract the upstream message for the JSON-RPC error (if JSON, use .message; otherwise trim)
199
+ let detail = '';
200
+ try {
201
+ const parsed = JSON.parse(text);
202
+ if (typeof parsed.message === 'string')
203
+ detail = `: ${parsed.message}`;
204
+ }
205
+ catch {
206
+ const trimmed = text.trim();
207
+ if (trimmed.length > 0 && trimmed.length <= 200)
208
+ detail = `: ${trimmed}`;
209
+ }
175
210
  process.stdout.write(JSON.stringify({
176
211
  jsonrpc: '2.0',
177
212
  id: requestId,
178
- error: { code: -32000, message: `HTTP ${response.status}` },
213
+ error: { code: -32000, message: `HTTP ${response.status}${detail}` },
179
214
  }) + '\n');
180
215
  return;
181
216
  }
@@ -256,14 +291,179 @@ export async function processLine(line, config, signer) {
256
291
  }) + '\n');
257
292
  }
258
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
+ }
259
440
  // --- Main entry ---
260
441
  export function startProxy() {
261
442
  const config = validateEnv();
262
443
  const signer = createSigner(config);
263
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
+ }
264
450
  let pending = Promise.resolve();
265
451
  rl.on('line', (line) => {
266
- 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(() => { });
267
467
  });
268
468
  rl.on('close', async () => {
269
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.2.1",
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": {