@deotio/mcp-sigv4-proxy 0.1.1 → 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.
- package/README.md +18 -5
- package/dist/proxy.js +123 -22
- 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://`.
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
log('WARNING', `dropping non-JSON response body: ${trimmed.slice(0, 100)}`);
|
|
127
223
|
}
|
|
128
224
|
}
|
|
129
225
|
}
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
261
|
+
pending = pending.then(() => processLine(line, config, signer)).catch(() => { });
|
|
161
262
|
});
|
|
162
263
|
rl.on('close', async () => {
|
|
163
|
-
|
|
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
|
-
|
|
269
|
+
log('INFO', 'received SIGTERM, shutting down');
|
|
169
270
|
rl.close();
|
|
170
271
|
});
|
|
171
272
|
process.on('SIGINT', () => {
|
|
172
|
-
|
|
273
|
+
log('INFO', 'received SIGINT, shutting down');
|
|
173
274
|
rl.close();
|
|
174
275
|
});
|
|
175
276
|
}
|
package/package.json
CHANGED