aws-sigv4-mcp-proxy
by steel8rat
README.md
# aws-sigv4-mcp-proxy
Bridge a plain-HTTP or stdio [MCP](https://modelcontextprotocol.io) client to an
**AWS SigV4-gated MCP endpoint** — Amazon Bedrock AgentCore runtimes, or any MCP
server behind API Gateway / a Lambda Function URL / IAM auth.
Most MCP clients (Claude Desktop, Cursor, VS Code, Kiro, the GitHub Copilot SDK, …)
can only be pointed at a static URL with static headers, or launched as a
`command` subprocess. None of them can compute a SigV4 signature per request, so
they cannot talk to an IAM-secured endpoint directly. This package sits in
between:
```
MCP client ──plain HTTP / stdio──▶ aws-sigv4-mcp-proxy ──SigV4-signed HTTPS──▶ AWS
```
It signs every forwarded request with SigV4 using whatever credentials the
process already has (env vars, SSO cache, an EC2 / ECS / AgentCore execution
role — the standard AWS credential chain), so the client never has to know AWS
auth exists.
This is a small, dependency-light TypeScript counterpart to AWS's Python
[`mcp-proxy-for-aws`](https://github.com/aws/mcp-proxy-for-aws). It exists because
embedding the Python tool (boto3 + botocore + uv) into a Node container adds
~250 MB; this package's runtime dependencies are the Smithy SigV4 signer and
a SHA-256 implementation (~6 MB unpacked, no AWS SDK client).
## Install
```bash
npm install aws-sigv4-mcp-proxy
```
To use the default AWS credential chain (recommended on ECS / Lambda / AgentCore),
also install the optional peer dependency:
```bash
npm install @aws-sdk/credential-provider-node
```
Requires Node.js ≥ 20 (uses the global `fetch`).
## CLI — stdio mode
The default mode reads newline-delimited JSON-RPC on stdin, signs and forwards
each message over MCP Streamable HTTP, and writes responses (JSON or
`text/event-stream`) back to stdout. This is how most MCP clients expect to launch
a server.
```bash
# Bedrock AgentCore runtime (service + region are inferred from the ARN)
aws-sigv4-mcp-proxy arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my_mcp-abcd1234
# Any other SigV4-gated endpoint
aws-sigv4-mcp-proxy --url https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp \
--service execute-api --region us-east-1
```
Example client config (`.mcp.json`, Claude Desktop, Cursor, VS Code — same shape):
```jsonc
{
"mcpServers": {
"my_remote_mcp": {
"command": "npx",
"args": [
"-y", "aws-sigv4-mcp-proxy",
"arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my_mcp-abcd1234"
],
"env": { "AWS_PROFILE": "my-sso-profile", "AWS_REGION": "us-east-1" }
}
}
}
```
## CLI — HTTP listener mode
`--http` starts a local unauthenticated listener instead. Point a client that
only accepts a static URL at it.
```bash
aws-sigv4-mcp-proxy --http --port 9100 \
arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my_mcp-abcd1234
# [aws-sigv4-mcp-proxy] listening on http://127.0.0.1:9100/mcp -> https://bedrock-agentcore...
```
```jsonc
{ "my_remote_mcp": { "type": "http", "url": "http://127.0.0.1:9100/mcp" } }
```
### All flags
| Flag | Description |
| --- | --- |
| `<url\|runtime-arn>` | Positional target: a URL, or an `arn:…` AgentCore runtime ARN |
| `--url` / `--runtime-arn` | Same as the positional, explicit form |
| `--http` | Run a local HTTP listener instead of the stdio bridge |
| `--service` | SigV4 service name (default `bedrock-agentcore` for an ARN; required with `--url`) |
| `--region` | AWS region (default: parsed from the ARN, else `$AWS_REGION` / `$AWS_DEFAULT_REGION`) |
| `--qualifier` | AgentCore runtime qualifier (default `DEFAULT`) |
| `--protocol-version` | Initial `MCP-Protocol-Version` header (stdio mode; updated from the `initialize` result) |
| `--no-server-stream` | Do not open a standalone GET SSE stream for server-initiated messages (stdio mode) |
| `--retry-empty-response` | Replay a request answered with an empty HTTP 200, up to 3 attempts — see [Empty responses](#empty-responses) |
| `--port` / `--host` / `--path` | HTTP listener bind settings (default `127.0.0.1`, ephemeral port, `/mcp`) |
## Programmatic API
### Generic core
```ts
import { startSigV4Proxy } from "aws-sigv4-mcp-proxy";
const proxy = await startSigV4Proxy({
targetUrl: "https://abc123.execute-api.us-east-1.amazonaws.com/prod/mcp",
service: "execute-api",
region: "us-east-1",
// credentials?: static creds or a provider; omit for the default chain
port: 9100, // optional; default: ephemeral
});
console.log(proxy.url); // http://127.0.0.1:9100/mcp
// ... on shutdown:
await proxy.close();
```
```ts
import { startStdioProxy } from "aws-sigv4-mcp-proxy";
const proxy = await startStdioProxy({
targetUrl,
service: "execute-api",
region: "us-east-1",
});
await proxy.done; // resolves when stdin ends and in-flight messages are flushed
```
### Bedrock AgentCore convenience layer
```ts
import { startAgentCoreProxy, startAgentCoreStdioProxy, agentCoreInvocationUrl } from "aws-sigv4-mcp-proxy";
const proxy = await startAgentCoreProxy({
runtimeArn: process.env.MY_MCP_RUNTIME_ARN!, // region + service inferred
qualifier: "DEFAULT", // optional
port: 9100, // optional
});
agentCoreInvocationUrl(runtimeArn);
// https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<url-encoded-arn>/invocations?qualifier=DEFAULT
```
## How the AgentCore invoke URL is derived
There is no published AWS REST reference for invoking an AgentCore runtime over
signed HTTP. The path shape here is reverse-engineered from AWS's own tooling —
the `agentcore` CLI's Smithy operation table (`InvokeAgentRuntime` →
`POST /runtimes/{agentRuntimeArn}/invocations`) and its URL builder — and matches
what the Python `mcp-proxy-for-aws` uses. AWS's tooling treats this endpoint as a
genuine MCP Streamable HTTP passthrough (raw JSON-RPC body, `Mcp-Session-Id` /
`Mcp-Protocol-Version` headers), which is what this proxy relies on.
It has been verified end to end against a live runtime. Still: cross-check it if
AWS publishes an official reference, and watch for it changing across `agentcore`
CLI versions. Override the pieces you need with `region`, `qualifier`, and
`dnsSuffix` (for non-standard partitions), or bypass the convenience layer
entirely with `startSigV4Proxy` + your own `targetUrl`.
## Design notes
- **Single target per instance.** One proxy instance maps to exactly one upstream
endpoint. It does not route by path or header to multiple targets — run one
instance per target. This keeps signing, session handling and error mapping
unambiguous, and is deliberate; please don't re-litigate it without a concrete
need.
- **Responses are streamed, not buffered.** `text/event-stream` responses (MCP's
mechanism for long-running calls and server notifications) are piped through
chunk by chunk in both modes.
- **stdio session handling.** The bridge tracks `Mcp-Session-Id` from the first
response and the negotiated protocol version from the `initialize` result, and
attaches both to subsequent requests. After initialization it opens a standalone
`GET` SSE stream for server-initiated messages, disabling it silently if the
upstream answers `4xx`/`5xx` (e.g. `405` when the server has no such stream).
- **Transport failures are JSON-RPC shaped.** An unreachable upstream or a non-2xx
response is returned to the client as
`{ "jsonrpc": "2.0", "id": <request id>, "error": { "code": -32001, "message": … } }`
rather than a bare HTTP failure.
- **Empty responses are reported, not passed through.** See below.
- **Header allowlist.** Request: `content-type`, `accept` (forced to
`application/json, text/event-stream` when missing or `*/*`), `mcp-session-id`,
`mcp-protocol-version`, `last-event-id`. Response: `content-type`,
`cache-control`, `mcp-session-id`, `mcp-protocol-version`, `www-authenticate`.
Both lists are overridable via `forwardRequestHeaders` / `forwardResponseHeaders`.
## Empty responses
An upstream can answer a JSON-RPC request with HTTP 200 and an empty body. MCP
clients then report a bare `Transport closed`, or — over stdio — wait forever.
Against Bedrock AgentCore this is how a failed or not-yet-listening container
surfaces: the platform masks the container's error as an empty 200.
**Reporting (always on).** When a request that is owed a response — a `POST` of a
single JSON-RPC request with a `method` and an `id` — gets none, the proxy answers
with a JSON-RPC error instead of silence:
```json
{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32603, "message": "Upstream returned HTTP 200 with an empty body" } }
```
- The HTTP status stays `200`, as JSON-RPC carries errors in the body. If the
upstream declared `text/event-stream`, the error is sent as one SSE `message`
event; otherwise as `application/json`.
- "Empty" means no non-whitespace byte before the body ends. Only the leading
bytes are read to decide this; SSE bodies keep streaming.
- In stdio mode, an SSE response that ends without a message answering the
request produces `-32603 "Upstream stream ended without a response"`.
- Notifications, client responses, batches, `GET` streams and `DELETE` pass an
empty 200 through unchanged — for them it is legitimate.
**Retry (off by default).** `retryEmptyResponse: true` (or
`{ attempts, backoffMs }`, defaults 3 total attempts and 150ms; CLI
`--retry-empty-response`) replays the identical signed request after an empty
200, with exponential, fully jittered backoff, before falling back to the error
above. Each retry is logged through `onWarn`.
> [!WARNING]
> Retrying trades at-most-once for **at-least-once** execution. A container that
> dies while writing its response has already run the tool, so a replay of a
> write tool applies it twice. MCP has no idempotency key that could make the
> replay safe (see SEP-1335 in `modelcontextprotocol/modelcontextprotocol`).
> The retry also hides container faults that the error would have surfaced —
> leave it off unless your tools are idempotent, and investigate the upstream
> either way.
### Limitations
- SSE resumption via `Last-Event-ID` is passed through but the standalone
server-stream does not yet reconnect automatically on drop.
- In HTTP mode, an SSE body is judged empty only by its bytes: a stream carrying
just keep-alive comments or events without a response is passed through, not
reported. (stdio mode parses the stream and does report it.)
- The HTTP listener is unauthenticated; bind it to loopback (the default) and
treat it as a local-only shim.
## Prior art
| Package | Why it doesn't fit |
| --- | --- |
| [`aws/mcp-proxy-for-aws`](https://github.com/aws/mcp-proxy-for-aws) | Python only; boto3/botocore too heavy to embed in a Node container |
| `mcp-proxy` (npm) | SSE ↔ stdio bridge, no AWS auth |
| `mcp-remote` (npm) | OAuth-based remote MCP proxy, not SigV4 |
| `@aws/bedrock-agentcore-sdk-typescript` | `RuntimeClient` only covers WebSocket/shell auth, no signed HTTP invoke |
| [`aws-sigv4-fetch`](https://github.com/zirkelc/aws-signature-v4) | The right signing primitive, but not a proxy |
## License
MIT
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues