@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.
- package/README.md +7 -4
- package/dist/proxy.js +208 -8
- 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
|
-
|
|
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.
|
|
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 | `
|
|
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
|
-
- **
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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(
|
|
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