@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.
Files changed (2) hide show
  1. package/dist/proxy.js +87 -28
  2. 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
- return { body, requestId };
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.sign(request);
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
- process.stdout.write(JSON.stringify({
288
- jsonrpc: '2.0',
289
- id: requestId,
290
- error: { code: -32000, message: 'Request signing failed — check AWS credentials' },
291
- }) + '\n');
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
- process.stdout.write(JSON.stringify({
308
- jsonrpc: '2.0',
309
- id: requestId,
310
- error: { code: -32000, message },
311
- }) + '\n');
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: '0.4.0' },
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.sign(request);
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(isCredentialError ? 'ERROR' : 'WARNING', `warm: initialize ${isCredentialError ? 'credential error' : 'error'} (${err}), retrying in ${delay}ms (${attempt + 1}/${config.warmRetries})`);
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.sign(request);
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
- log('WARNING', `warm: failed to prefetch ${method}: ${err}`);
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 (tryWarmResponse(input.body, input.requestId, ws))
520
- return true;
521
- // …otherwise wait for warm-up to complete, then try cache again before forwarding
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 still empty for ${method}, forwarding to backend`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deotio/mcp-sigv4-proxy",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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": {