@deotio/mcp-sigv4-proxy 0.1.1 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +18 -5
  2. package/dist/proxy.js +129 -23
  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,23 +98,29 @@ 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) {
115
+ const query = {};
116
+ url.searchParams.forEach((value, key) => {
117
+ query[key] = value;
118
+ });
58
119
  return new HttpRequest({
59
120
  method: 'POST',
60
121
  hostname: url.hostname,
61
- path: url.pathname + url.search,
122
+ path: url.pathname,
123
+ ...(Object.keys(query).length > 0 && { query }),
62
124
  headers: {
63
125
  'Content-Type': 'application/json',
64
126
  'Content-Length': String(Buffer.byteLength(body)),
@@ -67,10 +129,49 @@ export function buildHttpRequest(url, body) {
67
129
  body,
68
130
  });
69
131
  }
132
+ // --- Fetch with timeout and retries ---
133
+ async function fetchWithTimeout(url, init, timeoutMs) {
134
+ const controller = new AbortController();
135
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
136
+ try {
137
+ return await fetch(url, { ...init, signal: controller.signal });
138
+ }
139
+ finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
143
+ async function fetchWithRetry(url, init, timeoutMs, retries) {
144
+ let lastError;
145
+ for (let attempt = 0; attempt <= retries; attempt++) {
146
+ try {
147
+ const response = await fetchWithTimeout(url, init, timeoutMs);
148
+ // Only retry on 5xx server errors
149
+ if (response.status >= 500 && attempt < retries) {
150
+ log('WARNING', `HTTP ${response.status}, retrying (${attempt + 1}/${retries})`);
151
+ await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
152
+ continue;
153
+ }
154
+ return response;
155
+ }
156
+ catch (err) {
157
+ lastError = err;
158
+ if (attempt < retries) {
159
+ const isTimeout = err instanceof DOMException && err.name === 'AbortError';
160
+ log('WARNING', `request ${isTimeout ? 'timed out' : 'failed'}, retrying (${attempt + 1}/${retries})`);
161
+ await sleep(RETRY_BASE_MS * Math.pow(2, attempt));
162
+ }
163
+ }
164
+ }
165
+ throw lastError;
166
+ }
167
+ function sleep(ms) {
168
+ return new Promise((resolve) => setTimeout(resolve, ms));
169
+ }
170
+ // --- Response handling ---
70
171
  export async function handleResponse(response, requestId) {
71
172
  if (!response.ok) {
72
173
  const text = await response.text();
73
- process.stderr.write(`mcp-sigv4-proxy: HTTP ${response.status}: ${text}\n`);
174
+ log('ERROR', `HTTP ${response.status}: ${text}`);
74
175
  process.stdout.write(JSON.stringify({
75
176
  jsonrpc: '2.0',
76
177
  id: requestId,
@@ -90,7 +191,7 @@ export async function handleResponse(response, requestId) {
90
191
  break;
91
192
  buffer += decoder.decode(value, { stream: true });
92
193
  if (Buffer.byteLength(buffer) > MAX_SSE_BUFFER_BYTES) {
93
- process.stderr.write('mcp-sigv4-proxy: SSE buffer exceeded 1 MB limit, aborting stream\n');
194
+ log('ERROR', 'SSE buffer exceeded 1 MB limit, aborting stream');
94
195
  reader.cancel();
95
196
  process.stdout.write(JSON.stringify({
96
197
  jsonrpc: '2.0',
@@ -109,7 +210,7 @@ export async function handleResponse(response, requestId) {
109
210
  process.stdout.write(data + '\n');
110
211
  }
111
212
  catch {
112
- process.stderr.write(`mcp-sigv4-proxy: dropping non-JSON SSE data: ${data.slice(0, 100)}\n`);
213
+ log('WARNING', `dropping non-JSON SSE data: ${data.slice(0, 100)}`);
113
214
  }
114
215
  }
115
216
  }
@@ -123,53 +224,58 @@ export async function handleResponse(response, requestId) {
123
224
  process.stdout.write(trimmed + '\n');
124
225
  }
125
226
  catch {
126
- process.stderr.write(`mcp-sigv4-proxy: dropping non-JSON response body: ${trimmed.slice(0, 100)}\n`);
227
+ log('WARNING', `dropping non-JSON response body: ${trimmed.slice(0, 100)}`);
127
228
  }
128
229
  }
129
230
  }
130
- export async function processLine(line, url, signer) {
231
+ // --- Request processing ---
232
+ export async function processLine(line, config, signer) {
131
233
  const input = parseInputLine(line);
132
234
  if (!input)
133
235
  return;
134
236
  const { body, requestId } = input;
135
- const request = buildHttpRequest(url, body);
237
+ const request = buildHttpRequest(config.url, body);
136
238
  try {
137
239
  const signed = await signer.sign(request);
138
- const response = await fetch(url.toString(), {
240
+ log('DEBUG', `-> POST ${config.url.pathname}`);
241
+ const response = await fetchWithRetry(config.url.toString(), {
139
242
  method: 'POST',
140
243
  headers: signed.headers,
141
244
  body,
142
- });
245
+ }, config.timeoutMs, config.retries);
143
246
  await handleResponse(response, requestId);
144
247
  }
145
248
  catch (err) {
146
- process.stderr.write(`mcp-sigv4-proxy: request failed: ${err}\n`);
249
+ const isTimeout = err instanceof DOMException && err.name === 'AbortError';
250
+ const message = isTimeout ? 'Request timed out' : 'Proxy request failed';
251
+ log('ERROR', `request failed: ${err}`);
147
252
  process.stdout.write(JSON.stringify({
148
253
  jsonrpc: '2.0',
149
254
  id: requestId,
150
- error: { code: -32000, message: 'Proxy request failed' },
255
+ error: { code: -32000, message },
151
256
  }) + '\n');
152
257
  }
153
258
  }
259
+ // --- Main entry ---
154
260
  export function startProxy() {
155
261
  const config = validateEnv();
156
262
  const signer = createSigner(config);
157
263
  const rl = readline.createInterface({ input: process.stdin, terminal: false });
158
264
  let pending = Promise.resolve();
159
265
  rl.on('line', (line) => {
160
- pending = pending.then(() => processLine(line, config.url, signer)).catch(() => { });
266
+ pending = pending.then(() => processLine(line, config, signer)).catch(() => { });
161
267
  });
162
268
  rl.on('close', async () => {
163
- process.stderr.write('mcp-sigv4-proxy: stdin closed, draining in-flight requests\n');
269
+ log('INFO', 'stdin closed, draining in-flight requests');
164
270
  await pending;
165
271
  process.exit(0);
166
272
  });
167
273
  process.on('SIGTERM', () => {
168
- process.stderr.write('mcp-sigv4-proxy: received SIGTERM, shutting down\n');
274
+ log('INFO', 'received SIGTERM, shutting down');
169
275
  rl.close();
170
276
  });
171
277
  process.on('SIGINT', () => {
172
- process.stderr.write('mcp-sigv4-proxy: received SIGINT, shutting down\n');
278
+ log('INFO', 'received SIGINT, shutting down');
173
279
  rl.close();
174
280
  });
175
281
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deotio/mcp-sigv4-proxy",
3
- "version": "0.1.1",
3
+ "version": "0.2.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": {