@seal-dev/mcp-proxy
v0.2.0
Published
Self-hostable MCP-native approval gateway: a transparent proxy that gates tools/call on the Seal approval engine.
Maintainers
Readme
@seal-dev/mcp-proxy
An MCP-native approval gateway. It sits between an MCP client and an upstream MCP server,
relays every method untouched, and pauses tools/call when a policy says a human should
look first. The agent's code does not change.
npx @seal-dev/mcp-proxy --config seal.config.tsNo account, no network, no card.
Quickstart
Write seal.config.json (or .ts, .mjs, .js, .cjs) next to your project:
{
"policies": {
"refunds": {
"match": {},
"rules": [
{ "when": [{ "field": "payload.amount", "op": "lt", "value": 100 }], "then": "allow" },
{ "when": [{ "field": "payload.amount", "op": "gt", "value": 10000 }], "then": "deny" },
{ "when": [], "then": "require_approval" }
],
"approvers": { "channel": "dashboard", "timeoutSeconds": 300 }
}
},
"auditPath": "seal-audit.jsonl",
"upstreams": [
{
"name": "payments",
"transport": "stdio",
"command": "npx",
"args": ["-y", "@your/payments-mcp"],
"policyKey": "refunds"
}
]
}Point your MCP client at npx @seal-dev/mcp-proxy instead of the upstream server. Small
refunds go straight through. Large ones are refused. Everything in between stops and asks
you in the terminal:
────────────────────────────────────────────────────────────
Approve tool call `create_refund`?
Arguments:
{
"amount": 4200
}
[a] approve [r] reject (anything else rejects)
>Every evaluation, request and decision is appended to seal-audit.jsonl, hash-chained:
npx @seal-dev/mcp-proxy --verify seal-audit.jsonl
# audit chain intact — 6 events in seal-audit.jsonlEditing or deleting a record breaks the chain and --verify exits non-zero.
Policies
rules is an ordered list; the first rule whose when conditions all pass wins. A rule
with an empty when always passes — use it as the catch-all. then is one of:
| Verdict | Effect |
| ------------------ | --------------------------------------------------------------- |
| allow | Forwarded upstream immediately |
| deny | Refused immediately; the call never reaches the upstream |
| require_approval | Paused until a human decides, or until timeoutSeconds elapses |
If no rule matches, or the tool resolves to no policy at all, the verdict is
require_approval. Conditions read a dot-path against the request — payload.amount,
context.tier, action, agent — with eq, neq, gt, lt, contains and regex.
A missing field never passes.
Per-tool policy — policyKey may be one string for every call the upstream relays, or
a map:
"policyKey": {
"default": "read-only",
"overrides": { "git.push": "destructive", "git.reset": "destructive" }
}A tool that resolves to no policy key fails closed. A map with neither a default nor any
override is rejected at boot.
Fail-closed, by construction
- An unknown, missing or malformed input yields the safe verdict, never the permissive one.
- Only
aapproves at the prompt. Every other line, including an empty one, rejects. - A decision that never arrives expires, and an expired request is refused.
- A config with no
policies, or no controlling terminal to answer on, refuses to start rather than gating every call until it times out. - Tool descriptions from the upstream are untrusted (tool poisoning) and are sanitised before display. So are the arguments shown at the prompt: control, bidi and zero-width characters are stripped so a payload cannot move the cursor, repaint the prompt, or hide text behind an escape sequence.
How the approval reaches you
- Inline elicitation — if your MCP client advertised the
elicitationcapability oninitialize, the proxy asks through the client itself, in-session. Form mode requests no input fields, so no sensitive data is ever solicited. - The controlling terminal — otherwise the prompt above. In stdio mode stdin and stdout
are the MCP channel, so the proxy opens the controlling terminal (
/dev/tty,CONIN$on Windows) rather than corrupting the protocol. - Neither — if there is no terminal and the client cannot elicit, the proxy says so at startup instead of hanging. That is the point where the hosted product starts to matter.
Running it
npx @seal-dev/mcp-proxy # auto-discovers ./seal.config.*
npx @seal-dev/mcp-proxy --upstream payments # pick one when the config declares several
npx @seal-dev/mcp-proxy --config ./ci.config.ts --port 9000
npx @seal-dev/mcp-proxy --verify seal-audit.jsonlOne process fronts one upstream. Client-facing transports are stdio (the client launches the proxy) and Streamable HTTP with per-session routing; upstreams may be stdio or Streamable HTTP. A malformed config fails to boot with a specific error — it never starts permissive.
Upstream credentials are named by env var (envPassthrough, auth.valueEnv) and resolved
from the proxy's own environment. They are never sourced from the calling agent, never
stored and never logged.
When one machine stops being enough
Local mode is one developer at one terminal, and it is complete for that: policy, approval, audit, verification. It has no server, so there are things it structurally cannot do — a second person approving, an agent running in CI with nobody at the terminal, an auditor who needs more than a JSONL file on your laptop.
Adding a seal block switches the same config to the hosted product at
useseal.dev; approvals then arrive over Slack, email or the review
dashboard, and the audit trail is hosted and exportable.
{
"seal": { "apiUrl": "https://api.useseal.dev", "apiKeyEnv": "SEAL_API_KEY" },
"asyncMode": "hold", // hold (default) blocks the call; pending returns an id to resume
"upstreams": [...]
}Nothing in this package is switched off when you do. mode is inferred from the config: a
seal block means cloud, its absence means local, and declaring one while asserting the
other is a validation error rather than a silent precedence rule. In cloud mode policies
live in the dashboard, so a leftover local policies map is ignored — the proxy warns
rather than letting you believe it is still in force.
Inline elicitation in cloud mode submits decisions to POST /v1/approvals/:id/decision,
which needs the approvals:decide scope. Grant it only to a proxy that approves in-session;
a create-only key (approvals:write) still covers the async paths.
What the relay guarantees
A raw JSON-RPC pump between two transports. Client capabilities propagate on initialize
because the relay is transparent, and server→client requests (elicitation/create,
sampling/createMessage) round-trip instead of being dropped — which is what separates a
gateway from a one-way proxy. Session close is single and idempotent; send and transport
errors tear the session down rather than hanging.
Known gap: relaying an upstream-initiated server→client request over Streamable HTTP still
lacks the relatedRequestId association that the gate's own elicitation/create has. It
works today over stdio and is proven transport-agnostically in tests.
Scripts
| Script | Purpose |
| ---------------- | ---------------- |
| pnpm build | Bundle with tsup |
| pnpm typecheck | tsc -b |
| pnpm test | Vitest |
Apache-2.0.
