@deotio/mcp-sigv4-proxy 0.4.2 → 0.4.4
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/dist/proxy.js +87 -28
- package/package.json +1 -1
package/dist/proxy.js
CHANGED
|
@@ -14,6 +14,7 @@ const COLD_START_RETRY_MS = 5000;
|
|
|
14
14
|
const DEFAULT_WARM_RETRIES = 5;
|
|
15
15
|
const DEFAULT_WARM_RETRY_DELAY_MS = 10_000;
|
|
16
16
|
const DEFAULT_WARM_TIMEOUT_MS = 300_000; // 5 min
|
|
17
|
+
const DEFAULT_SIGN_TIMEOUT_MS = 10_000; // 10 s — fail fast if credentials are unavailable
|
|
17
18
|
const LOG_LEVEL_ORDER = {
|
|
18
19
|
DEBUG: 0,
|
|
19
20
|
INFO: 1,
|
|
@@ -82,6 +83,10 @@ export function validateEnv() {
|
|
|
82
83
|
const timeoutMs = process.env.MCP_TIMEOUT
|
|
83
84
|
? Number(process.env.MCP_TIMEOUT) * 1000
|
|
84
85
|
: DEFAULT_TIMEOUT_MS;
|
|
86
|
+
// Parse signing timeout
|
|
87
|
+
const signTimeoutMs = process.env.MCP_SIGN_TIMEOUT
|
|
88
|
+
? Number(process.env.MCP_SIGN_TIMEOUT) * 1000
|
|
89
|
+
: DEFAULT_SIGN_TIMEOUT_MS;
|
|
85
90
|
// Parse retries
|
|
86
91
|
const retries = process.env.MCP_RETRIES
|
|
87
92
|
? Math.min(Math.max(0, Math.floor(Number(process.env.MCP_RETRIES))), 10)
|
|
@@ -105,7 +110,7 @@ export function validateEnv() {
|
|
|
105
110
|
if (warm) {
|
|
106
111
|
log('INFO', `warm mode enabled (retries: ${warmRetries}, delay: ${warmRetryDelayMs}ms, timeout: ${warmTimeoutMs}ms)`);
|
|
107
112
|
}
|
|
108
|
-
return { url, region, service, timeoutMs, retries, warm, warmRetries, warmRetryDelayMs, warmTimeoutMs };
|
|
113
|
+
return { url, region, service, timeoutMs, retries, warm, warmRetries, warmRetryDelayMs, warmTimeoutMs, signTimeoutMs };
|
|
109
114
|
}
|
|
110
115
|
export function createSigner(config) {
|
|
111
116
|
return new SignatureV4({
|
|
@@ -115,6 +120,38 @@ export function createSigner(config) {
|
|
|
115
120
|
sha256: Sha256,
|
|
116
121
|
});
|
|
117
122
|
}
|
|
123
|
+
export async function signWithTimeout(signer, request, timeoutMs) {
|
|
124
|
+
let timer;
|
|
125
|
+
const timeout = new Promise((_, reject) => {
|
|
126
|
+
timer = setTimeout(() => reject(new Error(`credential resolution timed out after ${timeoutMs}ms — ` +
|
|
127
|
+
'run: aws sso login --profile <your-profile>')), timeoutMs);
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
return await Promise.race([signer.sign(request), timeout]);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
export async function probeCredentials(timeoutMs = 5_000) {
|
|
137
|
+
const provider = fromNodeProviderChain();
|
|
138
|
+
let timer;
|
|
139
|
+
const timeout = new Promise((_, reject) => {
|
|
140
|
+
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
141
|
+
});
|
|
142
|
+
try {
|
|
143
|
+
await Promise.race([provider(), timeout]);
|
|
144
|
+
log('INFO', `credentials OK (profile: ${process.env.AWS_PROFILE ?? 'default'})`);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
log('ERROR', `credential probe failed — AWS requests will fail until credentials are refreshed.\n` +
|
|
148
|
+
` Run: aws sso login --profile ${process.env.AWS_PROFILE ?? '<profile>'}\n` +
|
|
149
|
+
` Details: ${err}`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
118
155
|
// --- Input parsing ---
|
|
119
156
|
export function parseInputLine(line) {
|
|
120
157
|
const body = line.trim();
|
|
@@ -135,7 +172,8 @@ export function parseInputLine(line) {
|
|
|
135
172
|
return null;
|
|
136
173
|
}
|
|
137
174
|
const requestId = parsed.id ?? null;
|
|
138
|
-
|
|
175
|
+
const isNotification = !('id' in parsed);
|
|
176
|
+
return { body, requestId, isNotification };
|
|
139
177
|
}
|
|
140
178
|
// --- Request building ---
|
|
141
179
|
export function buildHttpRequest(url, body) {
|
|
@@ -150,6 +188,7 @@ export function buildHttpRequest(url, body) {
|
|
|
150
188
|
...(Object.keys(query).length > 0 && { query }),
|
|
151
189
|
headers: {
|
|
152
190
|
'Content-Type': 'application/json',
|
|
191
|
+
'Accept': 'application/json, text/event-stream',
|
|
153
192
|
'Content-Length': String(Buffer.byteLength(body)),
|
|
154
193
|
host: url.hostname,
|
|
155
194
|
},
|
|
@@ -276,19 +315,21 @@ export async function processLine(line, config, signer) {
|
|
|
276
315
|
const input = parseInputLine(line);
|
|
277
316
|
if (!input)
|
|
278
317
|
return;
|
|
279
|
-
const { body, requestId } = input;
|
|
318
|
+
const { body, requestId, isNotification } = input;
|
|
280
319
|
const request = buildHttpRequest(config.url, body);
|
|
281
320
|
let signed;
|
|
282
321
|
try {
|
|
283
|
-
signed = await signer.
|
|
322
|
+
signed = await signWithTimeout(signer, request, config.signTimeoutMs);
|
|
284
323
|
}
|
|
285
324
|
catch (err) {
|
|
286
325
|
log('ERROR', `signing failed (check AWS credentials for profile/env): ${err}`);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
326
|
+
if (!isNotification) {
|
|
327
|
+
process.stdout.write(JSON.stringify({
|
|
328
|
+
jsonrpc: '2.0',
|
|
329
|
+
id: requestId,
|
|
330
|
+
error: { code: -32000, message: 'Request signing failed — check AWS credentials' },
|
|
331
|
+
}) + '\n');
|
|
332
|
+
}
|
|
292
333
|
return;
|
|
293
334
|
}
|
|
294
335
|
try {
|
|
@@ -304,11 +345,13 @@ export async function processLine(line, config, signer) {
|
|
|
304
345
|
const isTimeout = err instanceof DOMException && err.name === 'AbortError';
|
|
305
346
|
const message = isTimeout ? 'Request timed out' : 'Proxy request failed';
|
|
306
347
|
log('ERROR', `request failed: ${err}`);
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
348
|
+
if (!isNotification) {
|
|
349
|
+
process.stdout.write(JSON.stringify({
|
|
350
|
+
jsonrpc: '2.0',
|
|
351
|
+
id: requestId,
|
|
352
|
+
error: { code: -32000, message },
|
|
353
|
+
}) + '\n');
|
|
354
|
+
}
|
|
312
355
|
}
|
|
313
356
|
}
|
|
314
357
|
const WARM_CACHEABLE = new Set([
|
|
@@ -339,7 +382,7 @@ export async function warmBackend(config, signer) {
|
|
|
339
382
|
params: {
|
|
340
383
|
protocolVersion: '2025-03-26',
|
|
341
384
|
capabilities: {},
|
|
342
|
-
clientInfo: { name: 'mcp-sigv4-proxy', version:
|
|
385
|
+
clientInfo: { name: 'mcp-sigv4-proxy', version: PROXY_VERSION },
|
|
343
386
|
},
|
|
344
387
|
});
|
|
345
388
|
let initResponse = null;
|
|
@@ -348,7 +391,7 @@ export async function warmBackend(config, signer) {
|
|
|
348
391
|
break;
|
|
349
392
|
try {
|
|
350
393
|
const request = buildHttpRequest(config.url, initBody);
|
|
351
|
-
const signed = await signer.
|
|
394
|
+
const signed = await signWithTimeout(signer, request, config.signTimeoutMs);
|
|
352
395
|
initResponse = await fetchWithTimeout(config.url.toString(), { method: 'POST', headers: signed.headers, body: initBody }, Math.min(config.timeoutMs, deadline - Date.now()));
|
|
353
396
|
if (initResponse.ok)
|
|
354
397
|
break;
|
|
@@ -373,10 +416,17 @@ export async function warmBackend(config, signer) {
|
|
|
373
416
|
const isCredentialError = err instanceof Error &&
|
|
374
417
|
(err.message.includes('credential') ||
|
|
375
418
|
err.message.includes('Could not load credentials') ||
|
|
376
|
-
err.message.includes('profile')
|
|
419
|
+
err.message.includes('profile') ||
|
|
420
|
+
err.message.includes('timed out')); // catches signWithTimeout errors
|
|
421
|
+
if (isCredentialError) {
|
|
422
|
+
// No point retrying — expired tokens won't fix themselves.
|
|
423
|
+
log('ERROR', `warm: credential error — aborting warm-up. ` +
|
|
424
|
+
`Run: aws sso login --profile <your-profile>\n Details: ${err}`);
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
377
427
|
if (attempt < config.warmRetries) {
|
|
378
428
|
const delay = config.warmRetryDelayMs * Math.pow(2, attempt);
|
|
379
|
-
log(
|
|
429
|
+
log('WARNING', `warm: initialize error (${err}), retrying in ${delay}ms (${attempt + 1}/${config.warmRetries})`);
|
|
380
430
|
await sleep(Math.min(delay, deadline - Date.now()));
|
|
381
431
|
continue;
|
|
382
432
|
}
|
|
@@ -418,7 +468,7 @@ export async function warmBackend(config, signer) {
|
|
|
418
468
|
jsonrpc: '2.0', id: `__warmup_${method}__`, method, params: {},
|
|
419
469
|
});
|
|
420
470
|
const request = buildHttpRequest(config.url, listBody);
|
|
421
|
-
const signed = await signer.
|
|
471
|
+
const signed = await signWithTimeout(signer, request, config.signTimeoutMs);
|
|
422
472
|
const response = await fetchWithTimeout(config.url.toString(), { method: 'POST', headers: signed.headers, body: listBody }, Math.min(config.timeoutMs, Math.max(1000, deadline - Date.now())));
|
|
423
473
|
if (response.ok) {
|
|
424
474
|
const body = await response.text();
|
|
@@ -460,7 +510,17 @@ export async function warmBackend(config, signer) {
|
|
|
460
510
|
}
|
|
461
511
|
}
|
|
462
512
|
catch (err) {
|
|
463
|
-
|
|
513
|
+
const isCredentialError = err instanceof Error &&
|
|
514
|
+
(err.message.includes('credential') ||
|
|
515
|
+
err.message.includes('Could not load credentials') ||
|
|
516
|
+
err.message.includes('profile') ||
|
|
517
|
+
err.message.includes('timed out'));
|
|
518
|
+
if (isCredentialError) {
|
|
519
|
+
log('ERROR', `warm: credential error during prefetch of ${method} — skipping. Details: ${err}`);
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
log('WARNING', `warm: failed to prefetch ${method}: ${err}`);
|
|
523
|
+
}
|
|
464
524
|
}
|
|
465
525
|
}));
|
|
466
526
|
state.active = true;
|
|
@@ -515,16 +575,13 @@ export async function handleWarmLine(input, ws) {
|
|
|
515
575
|
return true;
|
|
516
576
|
}
|
|
517
577
|
if (WARM_CACHEABLE.has(method)) {
|
|
518
|
-
// List methods: serve from cache immediately if available
|
|
519
|
-
if
|
|
520
|
-
|
|
521
|
-
//
|
|
522
|
-
log('INFO', `warm: cache miss for ${method}, waiting for warm-up to complete`);
|
|
523
|
-
const warmed = await ws.ready;
|
|
524
|
-
log('INFO', `warm: warm-up ${warmed ? 'succeeded' : 'failed'} — ${method} ${warmed ? 'served from cache' : 'forwarding to backend'}`);
|
|
578
|
+
// List methods: serve from cache immediately if available — never block on ws.ready.
|
|
579
|
+
// Warm-up is an optimistic pre-fetch; if it hasn't completed yet, fall through and
|
|
580
|
+
// let the backend answer directly. Blocking here would cause Claude Code's 60s
|
|
581
|
+
// tool-discovery timeout to fire before warm-up finishes.
|
|
525
582
|
if (tryWarmResponse(input.body, input.requestId, ws))
|
|
526
583
|
return true;
|
|
527
|
-
log('INFO', `warm: cache
|
|
584
|
+
log('INFO', `warm: cache miss for ${method}, forwarding to backend`);
|
|
528
585
|
}
|
|
529
586
|
// Non-cacheable methods (tools/call etc): return false to forward normally.
|
|
530
587
|
// fetchWithRetry handles any residual 424s if the backend isn't warm yet.
|
|
@@ -534,6 +591,8 @@ export async function handleWarmLine(input, ws) {
|
|
|
534
591
|
export function startProxy() {
|
|
535
592
|
const config = validateEnv();
|
|
536
593
|
const signer = createSigner(config);
|
|
594
|
+
// Non-blocking credential check — surfaces expired SSO immediately.
|
|
595
|
+
probeCredentials().catch(() => { });
|
|
537
596
|
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
|
538
597
|
// Start warm-up immediately. warmBackend() has no awaits before returning the WarmState
|
|
539
598
|
// object, so this promise resolves in the next microtask — effectively instant. The
|
package/package.json
CHANGED