@deotio/mcp-sigv4-proxy 0.1.0 → 0.2.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 +18 -5
  2. package/dist/proxy.js +123 -22
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @deotio/mcp-sigv4-proxy
2
2
 
3
+ > **Note:** AWS publishes an official proxy for this use case: [`mcp-proxy-for-aws`](https://github.com/aws/mcp-proxy-for-aws). It is mature, feature-rich, and AWS-supported. **Use it unless you specifically need a Node.js solution.** This package exists for teams that don't have (or want) a Python runtime — it provides the same core functionality as a single `npx` command with zero Python dependencies.
4
+
3
5
  A stdio MCP proxy that signs requests with AWS SigV4 using the standard credential chain. Drop it into your `.mcp.json` as a `command` entry to connect Claude Code (or any MCP client) to IAM-authenticated MCP servers like AWS Bedrock AgentCore — with per-profile auth via `AWS_PROFILE`.
4
6
 
5
7
  ## Quick start
@@ -12,12 +14,13 @@ Add to your `.mcp.json`:
12
14
  "args": ["-y", "@deotio/mcp-sigv4-proxy"],
13
15
  "env": {
14
16
  "AWS_PROFILE": "dot-finops",
15
- "AWS_REGION": "us-east-1",
16
17
  "MCP_SERVER_URL": "https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/.../invocations?qualifier=DEFAULT"
17
18
  }
18
19
  }
19
20
  ```
20
21
 
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
+
21
24
  ## How it works
22
25
 
23
26
  ```
@@ -27,7 +30,7 @@ stdin (JSON-RPC) -> validate -> SigV4 sign -> HTTPS POST -> response relay -> st
27
30
  1. Reads JSON-RPC messages from stdin (one per line)
28
31
  2. Validates each message is well-formed JSON-RPC 2.0
29
32
  3. Signs the request with AWS SigV4 using the configured credentials
30
- 4. Forwards to the target MCP endpoint via HTTPS
33
+ 4. Forwards to the target MCP endpoint via HTTPS (with configurable timeout and retries)
31
34
  5. Relays the response (JSON or SSE stream) back to stdout
32
35
 
33
36
  ## Environment variables
@@ -36,8 +39,11 @@ stdin (JSON-RPC) -> validate -> SigV4 sign -> HTTPS POST -> response relay -> st
36
39
  |---|---|---|---|
37
40
  | `MCP_SERVER_URL` | yes | — | Full HTTPS URL of the target MCP HTTP endpoint |
38
41
  | `AWS_PROFILE` | no | SDK default chain | AWS named profile for signing |
39
- | `AWS_REGION` | no | `us-east-1` | AWS region for SigV4 signing |
40
- | `AWS_SERVICE` | no | `bedrock-agentcore` | SigV4 service name |
42
+ | `AWS_REGION` | no | inferred from URL, then `us-east-1` | AWS region for SigV4 signing |
43
+ | `AWS_SERVICE` | no | inferred from URL, then `bedrock-agentcore` | SigV4 service name |
44
+ | `MCP_TIMEOUT` | no | `180` | Request timeout in seconds |
45
+ | `MCP_RETRIES` | no | `0` | Retry count for 5xx errors and network failures (0-10) |
46
+ | `MCP_LOG_LEVEL` | no | `ERROR` | Log verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `SILENT` |
41
47
 
42
48
  ## Prerequisites
43
49
 
@@ -49,12 +55,19 @@ bedrock-agentcore:InvokeAgentRuntime
49
55
 
50
56
  ## Security
51
57
 
52
- - **HTTPS-only** — `MCP_SERVER_URL` must use `https://`. Other schemes (`http://`, `file://`, `ftp://`) are rejected at startup to prevent SSRF.
58
+ - **HTTPS-only** — `MCP_SERVER_URL` must use `https://`. The only exception is `http://localhost` / `http://127.0.0.1` for local development.
53
59
  - **TLS enforcement** — the proxy refuses to start if `NODE_TLS_REJECT_UNAUTHORIZED=0` is set, since it sends signed AWS credentials.
54
60
  - **Input validation** — only well-formed JSON-RPC 2.0 messages are signed and forwarded.
55
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.
56
62
  - **Buffer limits** — SSE streams are capped at 1 MB to prevent unbounded memory growth.
57
63
 
64
+ ## Documentation
65
+
66
+ - [Why this package exists](doc/why.md) — the problem, the solution, and comparison with `mcp-proxy-for-aws`
67
+ - [Getting started](doc/getting-started.md) — step-by-step setup guide
68
+ - [Configuration](doc/configuration.md) — environment variables, credential methods, IAM permissions
69
+ - [Troubleshooting](doc/troubleshooting.md) — common errors and debugging tips
70
+
58
71
  ## License
59
72
 
60
73
  Apache-2.0
package/dist/proxy.js CHANGED
@@ -4,6 +4,41 @@ import { HttpRequest } from '@smithy/protocol-http';
4
4
  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
+ const DEFAULT_TIMEOUT_MS = 180_000; // 180s, matches AWS proxy
8
+ const DEFAULT_RETRIES = 0;
9
+ const RETRY_BASE_MS = 1000;
10
+ const LOG_LEVEL_ORDER = {
11
+ DEBUG: 0,
12
+ INFO: 1,
13
+ WARNING: 2,
14
+ ERROR: 3,
15
+ SILENT: 4,
16
+ };
17
+ let currentLogLevel = 'ERROR';
18
+ export function setLogLevel(level) {
19
+ currentLogLevel = level;
20
+ }
21
+ export function log(level, message) {
22
+ if (LOG_LEVEL_ORDER[level] >= LOG_LEVEL_ORDER[currentLogLevel]) {
23
+ process.stderr.write(`mcp-sigv4-proxy: ${message}\n`);
24
+ }
25
+ }
26
+ // --- URL parsing ---
27
+ export function parseEndpointUrl(hostname) {
28
+ const parts = hostname.split('.');
29
+ // bedrock-agentcore.us-east-1.amazonaws.com
30
+ if (parts.length >= 4 && parts.at(-2) === 'amazonaws' && parts.at(-1) === 'com') {
31
+ const service = parts.slice(0, -3).join('.');
32
+ const region = parts.at(-3);
33
+ if (service && region)
34
+ return { service, region };
35
+ }
36
+ // service.region.api.aws
37
+ if (parts.length === 4 && parts[2] === 'api' && parts[3] === 'aws') {
38
+ return { service: parts[0], region: parts[1] };
39
+ }
40
+ return null;
41
+ }
7
42
  export function validateEnv() {
8
43
  if (!process.env.MCP_SERVER_URL) {
9
44
  process.stderr.write('mcp-sigv4-proxy: MCP_SERVER_URL is required\n');
@@ -15,15 +50,35 @@ export function validateEnv() {
15
50
  process.exit(1);
16
51
  }
17
52
  const url = new URL(process.env.MCP_SERVER_URL);
18
- if (url.protocol !== 'https:') {
53
+ // Allow http:// for localhost (local development), require https:// for everything else
54
+ const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1';
55
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLocalhost)) {
19
56
  process.stderr.write(`mcp-sigv4-proxy: MCP_SERVER_URL must use https:// (got ${url.protocol})\n`);
20
57
  process.exit(1);
21
58
  }
22
- return {
23
- url,
24
- region: process.env.AWS_REGION ?? 'us-east-1',
25
- service: process.env.AWS_SERVICE ?? 'bedrock-agentcore',
26
- };
59
+ // Infer service and region from hostname, fall back to env vars / defaults
60
+ const inferred = parseEndpointUrl(url.hostname);
61
+ const region = process.env.AWS_REGION || inferred?.region || 'us-east-1';
62
+ const service = process.env.AWS_SERVICE || inferred?.service || 'bedrock-agentcore';
63
+ // Parse log level
64
+ const envLogLevel = (process.env.MCP_LOG_LEVEL ?? 'ERROR').toUpperCase();
65
+ if (envLogLevel in LOG_LEVEL_ORDER) {
66
+ setLogLevel(envLogLevel);
67
+ }
68
+ // Parse timeout
69
+ const timeoutMs = process.env.MCP_TIMEOUT
70
+ ? Number(process.env.MCP_TIMEOUT) * 1000
71
+ : DEFAULT_TIMEOUT_MS;
72
+ // Parse retries
73
+ const retries = process.env.MCP_RETRIES
74
+ ? Math.min(Math.max(0, Math.floor(Number(process.env.MCP_RETRIES))), 10)
75
+ : DEFAULT_RETRIES;
76
+ log('INFO', `target: ${url.hostname}, service: ${service}, region: ${region}`);
77
+ if (inferred) {
78
+ log('DEBUG', `inferred service=${inferred.service}, region=${inferred.region} from URL`);
79
+ }
80
+ log('DEBUG', `timeout: ${timeoutMs}ms, retries: ${retries}`);
81
+ return { url, region, service, timeoutMs, retries };
27
82
  }
28
83
  export function createSigner(config) {
29
84
  return new SignatureV4({
@@ -33,6 +88,7 @@ export function createSigner(config) {
33
88
  sha256: Sha256,
34
89
  });
35
90
  }
91
+ // --- Input parsing ---
36
92
  export function parseInputLine(line) {
37
93
  const body = line.trim();
38
94
  if (!body)
@@ -42,18 +98,19 @@ export function parseInputLine(line) {
42
98
  parsed = JSON.parse(body);
43
99
  }
44
100
  catch {
45
- process.stderr.write('mcp-sigv4-proxy: ignoring non-JSON input line\n');
101
+ log('WARNING', 'ignoring non-JSON input line');
46
102
  return null;
47
103
  }
48
104
  if (typeof parsed !== 'object' ||
49
105
  parsed === null ||
50
106
  parsed.jsonrpc !== '2.0') {
51
- process.stderr.write('mcp-sigv4-proxy: ignoring non-JSON-RPC message\n');
107
+ log('WARNING', 'ignoring non-JSON-RPC message');
52
108
  return null;
53
109
  }
54
110
  const requestId = parsed.id ?? null;
55
111
  return { body, requestId };
56
112
  }
113
+ // --- Request building ---
57
114
  export function buildHttpRequest(url, body) {
58
115
  return new HttpRequest({
59
116
  method: 'POST',
@@ -67,10 +124,49 @@ export function buildHttpRequest(url, body) {
67
124
  body,
68
125
  });
69
126
  }
127
+ // --- Fetch with timeout and retries ---
128
+ async function fetchWithTimeout(url, init, timeoutMs) {
129
+ const controller = new AbortController();
130
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
131
+ try {
132
+ return await fetch(url, { ...init, signal: controller.signal });
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ async function fetchWithRetry(url, init, timeoutMs, retries) {
139
+ let lastError;
140
+ for (let attempt = 0; attempt <= retries; attempt++) {
141
+ try {
142
+ const response = await fetchWithTimeout(url, init, timeoutMs);
143
+ // Only retry on 5xx server errors
144
+ if (response.status >= 500 && attempt < retries) {
145
+ log('WARNING', `HTTP ${response.status}, retrying (${attempt + 1}/${retries})`);
146
+ await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
147
+ continue;
148
+ }
149
+ return response;
150
+ }
151
+ catch (err) {
152
+ lastError = err;
153
+ if (attempt < retries) {
154
+ const isTimeout = err instanceof DOMException && err.name === 'AbortError';
155
+ log('WARNING', `request ${isTimeout ? 'timed out' : 'failed'}, retrying (${attempt + 1}/${retries})`);
156
+ await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
157
+ }
158
+ }
159
+ }
160
+ throw lastError;
161
+ }
162
+ function sleep(ms) {
163
+ return new Promise((resolve) => setTimeout(resolve, ms));
164
+ }
165
+ // --- Response handling ---
70
166
  export async function handleResponse(response, requestId) {
71
167
  if (!response.ok) {
72
168
  const text = await response.text();
73
- process.stderr.write(`mcp-sigv4-proxy: HTTP ${response.status}: ${text}\n`);
169
+ log('ERROR', `HTTP ${response.status}: ${text}`);
74
170
  process.stdout.write(JSON.stringify({
75
171
  jsonrpc: '2.0',
76
172
  id: requestId,
@@ -90,7 +186,7 @@ export async function handleResponse(response, requestId) {
90
186
  break;
91
187
  buffer += decoder.decode(value, { stream: true });
92
188
  if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
93
- process.stderr.write('mcp-sigv4-proxy: SSE buffer exceeded 1 MB limit, aborting stream\n');
189
+ log('ERROR', 'SSE buffer exceeded 1 MB limit, aborting stream');
94
190
  reader.cancel();
95
191
  process.stdout.write(JSON.stringify({
96
192
  jsonrpc: '2.0',
@@ -109,7 +205,7 @@ export async function handleResponse(response, requestId) {
109
205
  process.stdout.write(data + '\n');
110
206
  }
111
207
  catch {
112
- process.stderr.write(`mcp-sigv4-proxy: dropping non-JSON SSE data: ${data.slice(0, 100)}\n`);
208
+ log('WARNING', `dropping non-JSON SSE data: ${data.slice(0, 100)}`);
113
209
  }
114
210
  }
115
211
  }
@@ -123,53 +219,58 @@ export async function handleResponse(response, requestId) {
123
219
  process.stdout.write(trimmed + '\n');
124
220
  }
125
221
  catch {
126
- process.stderr.write(`mcp-sigv4-proxy: dropping non-JSON response body: ${trimmed.slice(0, 100)}\n`);
222
+ log('WARNING', `dropping non-JSON response body: ${trimmed.slice(0, 100)}`);
127
223
  }
128
224
  }
129
225
  }
130
- export async function processLine(line, url, signer) {
226
+ // --- Request processing ---
227
+ export async function processLine(line, config, signer) {
131
228
  const input = parseInputLine(line);
132
229
  if (!input)
133
230
  return;
134
231
  const { body, requestId } = input;
135
- const request = buildHttpRequest(url, body);
232
+ const request = buildHttpRequest(config.url, body);
136
233
  try {
137
234
  const signed = await signer.sign(request);
138
- const response = await fetch(url.toString(), {
235
+ log('DEBUG', `-> POST ${config.url.pathname}`);
236
+ const response = await fetchWithRetry(config.url.toString(), {
139
237
  method: 'POST',
140
238
  headers: signed.headers,
141
239
  body,
142
- });
240
+ }, config.timeoutMs, config.retries);
143
241
  await handleResponse(response, requestId);
144
242
  }
145
243
  catch (err) {
146
- process.stderr.write(`mcp-sigv4-proxy: request failed: ${err}\n`);
244
+ const isTimeout = err instanceof DOMException && err.name === 'AbortError';
245
+ const message = isTimeout ? 'Request timed out' : 'Proxy request failed';
246
+ log('ERROR', `request failed: ${err}`);
147
247
  process.stdout.write(JSON.stringify({
148
248
  jsonrpc: '2.0',
149
249
  id: requestId,
150
- error: { code: -32000, message: 'Proxy request failed' },
250
+ error: { code: -32000, message },
151
251
  }) + '\n');
152
252
  }
153
253
  }
254
+ // --- Main entry ---
154
255
  export function startProxy() {
155
256
  const config = validateEnv();
156
257
  const signer = createSigner(config);
157
258
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
158
259
  let pending = Promise.resolve();
159
260
  rl.on('line', (line) => {
160
- pending = pending.then(() => processLine(line, config.url, signer)).catch(() => { });
261
+ pending = pending.then(() => processLine(line, config, signer)).catch(() => { });
161
262
  });
162
263
  rl.on('close', async () => {
163
- process.stderr.write('mcp-sigv4-proxy: stdin closed, draining in-flight requests\n');
264
+ log('INFO', 'stdin closed, draining in-flight requests');
164
265
  await pending;
165
266
  process.exit(0);
166
267
  });
167
268
  process.on('SIGTERM', () => {
168
- process.stderr.write('mcp-sigv4-proxy: received SIGTERM, shutting down\n');
269
+ log('INFO', 'received SIGTERM, shutting down');
169
270
  rl.close();
170
271
  });
171
272
  process.on('SIGINT', () => {
172
- process.stderr.write('mcp-sigv4-proxy: received SIGINT, shutting down\n');
273
+ log('INFO', 'received SIGINT, shutting down');
173
274
  rl.close();
174
275
  });
175
276
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deotio/mcp-sigv4-proxy",
3
- "version": "0.1.0",
3
+ "version": "0.2.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": {