@deotio/mcp-sigv4-proxy 0.3.0 → 0.4.1
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/README.md +5 -3
- package/dist/proxy.js +212 -2
- package/package.json +4 -4
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.
|
|
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 | `
|
|
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
|
-
- **
|
|
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
|
-
|
|
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,206 @@ 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
|
+
// Exported for testing
|
|
298
|
+
export { syntheticInitializeResult };
|
|
299
|
+
/**
|
|
300
|
+
* Synthetic MCP initialize response returned when the backend hasn't responded yet.
|
|
301
|
+
* Advertises tools/resources/prompts capabilities so Claude Code proceeds to list calls.
|
|
302
|
+
*/
|
|
303
|
+
function syntheticInitializeResult() {
|
|
304
|
+
return {
|
|
305
|
+
protocolVersion: '2025-03-26',
|
|
306
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
307
|
+
serverInfo: { name: 'mcp-sigv4-proxy-warm', version: '0.4.1' },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
export async function warmBackend(config, signer) {
|
|
311
|
+
const state = { ready: Promise.resolve(false), cache: {}, active: false };
|
|
312
|
+
state.ready = (async () => {
|
|
313
|
+
const deadline = Date.now() + config.warmTimeoutMs;
|
|
314
|
+
// Step 1: Initialize the backend (retry through cold-start 424s)
|
|
315
|
+
const initBody = JSON.stringify({
|
|
316
|
+
jsonrpc: '2.0',
|
|
317
|
+
id: '__warmup_init__',
|
|
318
|
+
method: 'initialize',
|
|
319
|
+
params: {
|
|
320
|
+
protocolVersion: '2025-03-26',
|
|
321
|
+
capabilities: {},
|
|
322
|
+
clientInfo: { name: 'mcp-sigv4-proxy', version: '0.4.0' },
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
let initResponse = null;
|
|
326
|
+
for (let attempt = 0; attempt <= config.warmRetries; attempt++) {
|
|
327
|
+
if (Date.now() >= deadline)
|
|
328
|
+
break;
|
|
329
|
+
try {
|
|
330
|
+
const request = buildHttpRequest(config.url, initBody);
|
|
331
|
+
const signed = await signer.sign(request);
|
|
332
|
+
initResponse = await fetchWithTimeout(config.url.toString(), { method: 'POST', headers: signed.headers, body: initBody }, Math.min(config.timeoutMs, deadline - Date.now()));
|
|
333
|
+
if (initResponse.ok)
|
|
334
|
+
break;
|
|
335
|
+
if (initResponse.status === 424 && attempt < config.warmRetries) {
|
|
336
|
+
const delay = config.warmRetryDelayMs * Math.pow(2, attempt);
|
|
337
|
+
log('INFO', `warm: cold-start (HTTP 424), retrying in ${delay}ms (${attempt + 1}/${config.warmRetries})`);
|
|
338
|
+
await sleep(Math.min(delay, deadline - Date.now()));
|
|
339
|
+
initResponse = null;
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
log('WARNING', `warm: initialize failed with HTTP ${initResponse.status}`);
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
if (attempt < config.warmRetries) {
|
|
347
|
+
const delay = config.warmRetryDelayMs * Math.pow(2, attempt);
|
|
348
|
+
log('WARNING', `warm: initialize error (${err}), retrying in ${delay}ms`);
|
|
349
|
+
await sleep(Math.min(delay, deadline - Date.now()));
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
log('ERROR', `warm: initialize failed after ${config.warmRetries} retries: ${err}`);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (!initResponse?.ok) {
|
|
357
|
+
log('ERROR', 'warm: backend did not respond to initialize within timeout');
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
// Check for session ID — warm mode is incompatible with stateful servers
|
|
361
|
+
const sessionId = initResponse.headers.get('mcp-session-id');
|
|
362
|
+
if (sessionId) {
|
|
363
|
+
log('WARNING', 'warm: backend returned mcp-session-id header — stateful servers are not '
|
|
364
|
+
+ 'compatible with warm mode. Falling back to pass-through mode.');
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
// Cache the initialize result (extract from JSON-RPC response wrapper)
|
|
368
|
+
try {
|
|
369
|
+
const body = await initResponse.text();
|
|
370
|
+
const parsed = JSON.parse(body);
|
|
371
|
+
if (parsed.result) {
|
|
372
|
+
state.cache.initialize = parsed.result;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
state.cache.initialize = syntheticInitializeResult();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
state.cache.initialize = syntheticInitializeResult();
|
|
380
|
+
}
|
|
381
|
+
log('INFO', 'warm: backend initialized, prefetching capability lists');
|
|
382
|
+
// Step 2: Prefetch tools/list, resources/list, prompts/list
|
|
383
|
+
const listMethods = ['tools/list', 'resources/list', 'prompts/list'];
|
|
384
|
+
await Promise.all(listMethods.map(async (method) => {
|
|
385
|
+
try {
|
|
386
|
+
const listBody = JSON.stringify({
|
|
387
|
+
jsonrpc: '2.0', id: `__warmup_${method}__`, method, params: {},
|
|
388
|
+
});
|
|
389
|
+
const request = buildHttpRequest(config.url, listBody);
|
|
390
|
+
const signed = await signer.sign(request);
|
|
391
|
+
const response = await fetchWithTimeout(config.url.toString(), { method: 'POST', headers: signed.headers, body: listBody }, Math.min(config.timeoutMs, Math.max(1000, deadline - Date.now())));
|
|
392
|
+
if (response.ok) {
|
|
393
|
+
const body = await response.text();
|
|
394
|
+
const parsed = JSON.parse(body);
|
|
395
|
+
if (parsed.result) {
|
|
396
|
+
state.cache[method] = parsed.result;
|
|
397
|
+
log('DEBUG', `warm: cached ${method} (${JSON.stringify(parsed.result).length} bytes)`);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch (err) {
|
|
402
|
+
log('WARNING', `warm: failed to prefetch ${method}: ${err}`);
|
|
403
|
+
}
|
|
404
|
+
}));
|
|
405
|
+
state.active = true;
|
|
406
|
+
const cached = Object.keys(state.cache).length;
|
|
407
|
+
log('INFO', `warm: ready (${cached} responses cached)`);
|
|
408
|
+
return true;
|
|
409
|
+
})();
|
|
410
|
+
return state;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Process a line in warm mode. Returns true if the message was handled locally
|
|
414
|
+
* (from cache or synthetic response), false if it should be forwarded to the backend.
|
|
415
|
+
*/
|
|
416
|
+
export function tryWarmResponse(body, requestId, warmState) {
|
|
417
|
+
let parsed;
|
|
418
|
+
try {
|
|
419
|
+
parsed = JSON.parse(body);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
const method = parsed.method;
|
|
425
|
+
if (!method || !WARM_CACHEABLE.has(method))
|
|
426
|
+
return false;
|
|
427
|
+
const cached = warmState.cache[method];
|
|
428
|
+
if (!cached)
|
|
429
|
+
return false;
|
|
430
|
+
// Respond from cache
|
|
431
|
+
const response = JSON.stringify({
|
|
432
|
+
jsonrpc: '2.0',
|
|
433
|
+
id: requestId,
|
|
434
|
+
result: cached,
|
|
435
|
+
});
|
|
436
|
+
process.stdout.write(response + '\n');
|
|
437
|
+
log('DEBUG', `warm: served ${method} from cache`);
|
|
438
|
+
// For initialize, also send the initialized notification to the backend (fire-and-forget).
|
|
439
|
+
// Claude Code sends this too, but in warm mode we intercepted initialize so we handle it.
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Handle a single parsed JSON-RPC message in warm mode.
|
|
444
|
+
* Returns true if the message was served locally (no need to forward to backend).
|
|
445
|
+
*/
|
|
446
|
+
export async function handleWarmLine(input, ws) {
|
|
447
|
+
const method = JSON.parse(input.body).method;
|
|
448
|
+
if (method === 'initialize') {
|
|
449
|
+
// Respond IMMEDIATELY — never block on ws.ready here.
|
|
450
|
+
// This is the critical path: Claude Code's 30s timeout applies to this response.
|
|
451
|
+
const result = ws.cache.initialize ?? syntheticInitializeResult();
|
|
452
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: input.requestId, result }) + '\n');
|
|
453
|
+
log('DEBUG', `warm: served initialize ${ws.cache.initialize ? 'from cache' : 'synthetic'}`);
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
if (WARM_CACHEABLE.has(method)) {
|
|
457
|
+
// List methods: serve from cache immediately if available…
|
|
458
|
+
if (tryWarmResponse(input.body, input.requestId, ws))
|
|
459
|
+
return true;
|
|
460
|
+
// …otherwise wait for warm-up to complete, then try cache again before forwarding
|
|
461
|
+
await ws.ready;
|
|
462
|
+
if (tryWarmResponse(input.body, input.requestId, ws))
|
|
463
|
+
return true;
|
|
464
|
+
// Fall through to forward if warm-up failed or cache still empty
|
|
465
|
+
}
|
|
466
|
+
// Non-cacheable methods (tools/call etc): return false to forward normally.
|
|
467
|
+
// fetchWithRetry handles any residual 424s if the backend isn't warm yet.
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
276
470
|
// --- Main entry ---
|
|
277
471
|
export function startProxy() {
|
|
278
472
|
const config = validateEnv();
|
|
279
473
|
const signer = createSigner(config);
|
|
280
474
|
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
475
|
+
// Start warm-up immediately. warmBackend() has no awaits before returning the WarmState
|
|
476
|
+
// object, so this promise resolves in the next microtask — effectively instant. The
|
|
477
|
+
// warmState.ready promise inside it is what takes minutes to resolve.
|
|
478
|
+
const warmStateP = config.warm
|
|
479
|
+
? warmBackend(config, signer)
|
|
480
|
+
: null;
|
|
281
481
|
let pending = Promise.resolve();
|
|
282
482
|
rl.on('line', (line) => {
|
|
283
|
-
pending = pending.then(
|
|
483
|
+
pending = pending.then(async () => {
|
|
484
|
+
if (warmStateP) {
|
|
485
|
+
const input = parseInputLine(line);
|
|
486
|
+
if (input) {
|
|
487
|
+
const ws = await warmStateP; // resolves almost instantly
|
|
488
|
+
if (await handleWarmLine(input, ws))
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
await processLine(line, config, signer);
|
|
493
|
+
}).catch(() => { });
|
|
284
494
|
});
|
|
285
495
|
rl.on('close', async () => {
|
|
286
496
|
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
|
+
"version": "0.4.1",
|
|
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": {
|
|
@@ -19,16 +19,16 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@aws-sdk/credential-providers": "^3.0.0",
|
|
21
21
|
"@aws-crypto/sha256-js": "^5.0.0",
|
|
22
|
-
"@smithy/protocol-http": "^
|
|
22
|
+
"@smithy/protocol-http": "^5.3.12",
|
|
23
23
|
"@smithy/signature-v4": "^4.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@types/jest": "^
|
|
26
|
+
"@types/jest": "^30.0.0",
|
|
27
27
|
"@types/node": "^22.0.0",
|
|
28
28
|
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
29
29
|
"@typescript-eslint/parser": "^7.0.0",
|
|
30
30
|
"eslint": "^8.0.0",
|
|
31
|
-
"jest": "^
|
|
31
|
+
"jest": "^30.3.0",
|
|
32
32
|
"prettier": "^3.0.0",
|
|
33
33
|
"ts-jest": "^29.0.0",
|
|
34
34
|
"typescript": "^5.0.0"
|