MCP Gateway
vanzik v2.0updated view on github
The vanzik-mcp-gateway binary brokers JSON-RPC between an MCP
host (Claude Desktop, Cursor, Cline, Continue, Zed) and one or more
downstream MCP servers, gating every tools/call through the policy
engine. It supports multi-upstream configurations, capability merging,
namespaced tools, reconnect with backoff, and typed JSON-RPC error
responses.
For cross-cutting concerns (binary structure, audit transport tag,
deployment topologies, fail-mode flag) see
docs/PROXY_ARCHITECTURE.md.
1. Architecture#
┌──────────────────────────────────────┐
│ MCP host (Claude Desktop, Cursor,…) │
│ spawns the gateway as a subprocess │
└───────────────────┬──────────────────┘
│ stdio (JSON-RPC, newline-delimited UTF-8)
│
┌───────────────────▼──────────────────┐
│ vanzik-mcp-gateway │
│ │
│ • dispatcher: route by ns prefix │
│ • policy: Engine.Check via │
│ --guard-url /v1/check (HTTP) │
│ • audit: none here; the central │
│ server audits each /v1/check │
│ • upstream pool (one per ns): │
│ • subprocess.Popen + reader/ │
│ writer goroutines │
│ • reconnect-with-backoff │
└───┬─────────────────┬────────────────┘
│ │ …
stdio │ │ stdio
│ │
┌──────────▼─────┐ ┌────────▼────────┐
│ fs MCP server │ │ github MCP server│
│ (filesystem) │ │ │
└────────────────┘ └─────────────────┘
The gateway is a single Go process with one stdio in (the MCP host)
and N stdio out subprocesses (downstream MCP servers). Remote servers
can also be reached over Streamable HTTP with --upstream-http
(§ 8.5). It does not embed an HTTP server for client-facing
traffic — the MCP host always talks stdio. It does open an HTTP client
to call the central Vanzik server's /v1/check.
1.1 Why Go (not Python)#
The Go gateway is a thin client of the central server: it reads the
policy file only for tool_scope_map, and every decision is an HTTP
/v1/check call. The engine, the approval queue, the audit log and the
notifications all live in the central server. The only file the gateway
writes is the --fail-audit-log JSONL under --fail-mode fail-closed-with-audit. The Python GuardedMCPGateway in
plugins/python/vanzik/adapters/mcp.py remains as a fallback for
environments that can't run a Go binary.
2. CLI surface#
vanzik-mcp-gateway \
--upstream "fs:npx -y @modelcontextprotocol/server-filesystem /tmp" \
--upstream "github:docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server" \
--upstream "everything:npx -y @modelcontextprotocol/server-everything" \
--guard-url http://127.0.0.1:8080 \
--policy /etc/vanzik/policy.yaml \
--api-key $VANZIK_API_KEY \
--tenant-id local \
--fail-mode deny \
--log-level info
| flag | repeatable | meaning |
|---|---|---|
--upstream "<ns>:<cmd>" |
yes | Downstream MCP server. ns is the namespace prefix; cmd is tokenized by the gateway's own SplitCommandLine (double quotes and \ escapes only — no single quotes, $VAR expansion, or pipes). If ns: is omitted, the namespace defaults to the first whitespace-delimited token of cmd. |
--upstream-http "<ns>:<url>" |
yes | Remote MCP server reached over Streamable HTTP (§ 8.5). ns is required and follows the --upstream rules; namespaces are unique across both flags. The URL must be https, or http to localhost, 127.0.0.1 or [::1]. Credentials in the URL are refused; use --upstream-header. |
--upstream-header "<ns>:<Name>=<Value>" |
yes | Extra HTTP header sent on every request to the --upstream-http server ns. ${VAR} in the value is read from the gateway's environment, so a token can live in the client config's env block instead of args. An unset variable, an unknown or stdio namespace, and headers the transport sets itself (Accept, Content-Type, Mcp-Session-Id, MCP-Protocol-Version, ...) are errors. Header values are never logged. |
--guard-url |
no | central server URL. Falls back to VANZIK_URL env, then http://127.0.0.1:8080. Must be an http/https URL with a host. |
--api-key |
no | bearer for /v1/check. Falls back to VANZIK_API_KEY env, then to the key vanzik setup saved — so an MCP client config needs no secret in it. |
--tenant-id |
no | default local. Must be non-empty. |
--fail-mode |
no | deny / allow / fail-closed-with-audit. Default deny. fail-closed-with-audit denies with the distinct Rule deny:gateway:fail_closed_audit and records the denial in the local --fail-audit-log file. See PROXY_ARCHITECTURE.md § 6.1. |
--fail-audit-log |
no | local JSONL fallback audit file for fail-closed-with-audit denials. Default vanzik-fail-audit.jsonl; empty disables. |
--policy |
no | Path to Vanzik policy YAML. Required when --policy-mode strict (the default). Used to resolve tool_scope_map overrides for the dual-check (mcp_tool + mapped scope). |
--policy-mode |
no | strict (default) or fast. strict requires --policy and fails closed if the file is missing/invalid; fast runs only the mcp_tool check and never consults tool_scope_map (--policy is optional). |
--log-level |
no | stderr verbosity. Default info. |
--upstream-timeout |
no | per-frame upstream-response timeout. Default 30s. |
--reconnect-cap |
no | longest wait between attempts to restart an upstream that exited, or to reach an unreachable --upstream-http server again. The waits grow 1s, 2s, 5s, 30s, 60s and stop at this value. Default 60s. |
--version |
no | print version and exit. Checked before any other flag is parsed, so it works without --upstream. |
The gateway takes flags only. A stray word is an error (exit 2) instead of
being ignored: vanzik-mcp-gateway version answers did you mean --version?, and an unquoted --upstream fs:npx … /tmp reports the lost
/tmp rather than launching the server without it. Quote each --upstream
command.
A remote MCP server joins the same way, with its token taken from the environment:
DOCS_TOKEN=... vanzik-mcp-gateway \
--upstream "fs:npx -y @modelcontextprotocol/server-filesystem /tmp" \
--upstream-http "docs:https://mcp.example.com/mcp" \
--upstream-header 'docs:Authorization=Bearer ${DOCS_TOKEN}' \
--policy /etc/vanzik/policy.yaml
The host still talks to the gateway over stdio. Its tools appear as
docs:<tool> and are gated like any other namespace.
Stdout is reserved for JSON-RPC. All logging goes to stderr — the MCP spec explicitly permits this (the host MAY capture or ignore it).
3. Protocol negotiation#
3.1 Supported MCP protocol versions#
var SupportedProtocolVersions = []string{
"2025-11-25", // current spec, default
"2025-06-18", // adds the MCP-Protocol-Version HTTP header
"2025-03-26", // streamable HTTP era
"2024-11-05", // legacy stdio (still common in the field)
}
The Python SDK adapter is pinned to 2024-11-05 (MCP_PROTOCOL_VERSION
in plugins/python/vanzik/adapters/mcp.py). The Go gateway
deliberately accepts a wider set so it can sit between a recent client
and an older downstream (or vice versa).
3.2 Initialize handshake#
Sequence:
Host sends
initializeto the gateway over stdio:{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-11-25", "capabilities": { "roots": { "listChanged": true } }, "clientInfo": { "name": "Claude Desktop", "version": "1.x" } } }The gateway picks the highest version in
SupportedProtocolVersionsthat is ≤ the requested version. If the requested version is older than the gateway's lowest supported version, it returns the JSON-RPC error spec'd by the MCP lifecycle doc:{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "Unsupported protocol version", "data": { "supported": ["2025-11-25","2025-06-18","2025-03-26","2024-11-05"], "requested": "1999-01-01" } } }The gateway forwards
initializeto every configured upstream with the negotiated version. If any upstream returns a different version (downstream pinned older), the gateway downgrades the session to the lowest common denominator and logs WARN.The gateway returns its own
initializeresponse to the host:{ "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-11-25", "serverInfo": { "name": "vanzik-mcp-gateway", "version": "2.0.0" }, "capabilities": { "tools": { "listChanged": true } } } }The gateway does not pretend to be the downstream — its
serverInfoalways identifies it asvanzik-mcp-gateway.Host sends the
notifications/initializednotification (no id, no response). The gateway forwards this to every upstream.Only
notifications/*methods may be sent without an id. Any other id-less frame (an id-lesstools/call, say) is dropped and logged: forwarded, it would reach the upstreams without a policy check. Notifications go to the upstreams in the background, so a slow upstream does not hold up the host's next request.
3.3 Capability merging#
The gateway's capabilities returned to the host is the union of
each downstream's capabilities, intersected with what the gateway
itself can faithfully proxy.
| capability | gateway behaviour |
|---|---|
tools |
always advertised. listChanged: true — the gateway forwards upstream notifications/tools/list_changed to the host so clients re-pull tools/list when any upstream's tool set changes. |
resources |
masked out — never advertised, even when an upstream supports it. resources/* routing is not implemented, and advertising it would show the host resources that every resources/read rejects with -32601 MethodNotFound. |
prompts |
masked out — never advertised, same rationale as resources (prompts/* routing is not implemented). |
logging |
always advertised; gateway forwards logging/setLevel to every upstream. |
completions |
not advertised. |
4. Multi-upstream routing#
4.1 Namespace prefixing#
Each --upstream "<ns>:<cmd>" registers a namespace. Tool names from
that upstream are prefixed <ns>:<toolname> when surfaced to the host.
The colon delimiter is chosen because the MCP tool-name spec
(2025-11-25/server/tools) explicitly allows _, -, ., plus ASCII
alphanumerics. Colon is not in the SHOULD-allowed set, so it
unambiguously identifies the prefix as gateway-injected.
Design note — colon vs dot. MCP's tool name SHOULD-set is
[A-Za-z0-9._-]. Dots are allowed and are sometimes used by upstream servers (e.g.,admin.tools.listfrom the spec example). Using.as the prefix delimiter would create ambiguity with such upstreams. Colon stands out and the spec only says SHOULD on the allowed-char set, so this is a controlled deviation.
4.2 tools/list aggregation#
// host → gateway
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }
The gateway fans out tools/list to every healthy upstream (skipping
degraded namespaces — see § 7). It receives N responses, each with its
own result.tools array, and merges them by name-prefixing every tool
and concatenating the arrays.
// gateway → host
{
"jsonrpc": "2.0", "id": 2,
"result": {
"tools": [
{ "name": "fs:read_file", "description": "...", "inputSchema": {...} },
{ "name": "fs:write_file", "description": "...", "inputSchema": {...} },
{ "name": "github:list_issues", "description": "...", "inputSchema": {...} }
]
}
}
description and inputSchema are passed through unchanged.
The gateway doesn't paginate toward the host. It ignores a host's
params.cursor, asks every healthy upstream in parallel, follows each
upstream's nextCursor until that upstream has no more pages (at most
64 pages per upstream), and returns every tool in one list with no
nextCursor. The order of namespaces in the list isn't fixed. An
upstream that is down or returns an error is left out of the list (the
error goes to the gateway's log), and an upstream tool whose own name
contains : is skipped because it would clash with the namespace
prefix.
4.3 tools/call routing#
// host → gateway
{
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "fs:read_file", "arguments": { "path": "/tmp/x" } }
}
The gateway:
- Splits the name on the first
:. Left = namespace; right = upstream tool name. - If no
:is present or the namespace doesn't match a configured upstream: return-32602 Invalid params: unknown namespace. - Runs policy check (see § 4.4).
- On
ALLOW, rewrites the request to drop the prefix and forwards to the upstream:{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "read_file", "arguments": { "path": "/tmp/x" } } } - Relays the upstream's response to the host verbatim.
4.4 Policy mapping — the dual-check decision#
Recommendation: dual-check by default. Each tools/call results in
two Engine.Check calls, each a /v1/check request to the central
server:
- One with
scope: "mcp_tool",command: "<ns>:<toolname>", and each argument inmeta["arg_<key>"](see § 5). - One with the mapped scope if the tool name (or its arguments) maps
to an existing scope. E.g.:
fs:read_filewithpath: "/etc/passwd"→ also checkscope: "filesystem",path: "/etc/passwd",action: "read". The action comes from the tool name's verb: search and tree tools (search_files,directory_tree) are reads too.fs:read_multiple_fileswithpaths: ["/tmp/a", "/etc/passwd"]→ onefilesystemcheck per entry of thepathsarray.github:fetch_urlwithurl: "https://api.github.com/..."→ also checkscope: "network",url: ...,domain: "api.github.com".
The forwarded action proceeds only if every check returns ALLOW. If any denies, the call denies. If any requires approval, the call requires approval, with the first approval to fire.
4.4.1 Why dual-check#
Operators write existing scope rules:
- scope: filesystem
deny:
- action: read
paths: ["/etc/**", "/root/**"]
- action: write
paths: ["/etc/**", "/root/**"]
A filesystem path rule needs an action (read, write or delete);
without one it matches nothing.
…and they want those rules to apply whether the agent reaches
/etc/passwd via the SDK, the LLM proxy's tool-call extraction, or
an MCP fs:read_file. Single-check (mcp_tool only) breaks that
expectation: the operator would have to duplicate every filesystem rule
into an mcp_tool rule. Dual-check honours the existing rules without
duplication.
4.4.2 Cost: two Engine.Check calls per tool call#
In a microbenchmark, one Engine.Check runs ~5–15 µs on a
small policy. Two is still ≤ 30 µs — well below the network round-trip
to a downstream MCP server. The cost is acceptable.
4.4.3 Configurable, but on by default#
vanzik-mcp-gateway --policy-mode strict # dual-check (default)
vanzik-mcp-gateway --policy-mode fast # single-check (mcp_tool only)
fast mode dispatches one check with scope: "mcp_tool" and never
consults tool_scope_map, so only mcp_tool rules apply — filesystem,
network and shell rules never see the call. That's why strict is the
default.
4.4.4 Tool-scope mapping table#
The mapping comes only from the policy's tool_scope_map — the gateway
has no built-in table. A tool that matches no entry is checked under
mcp_tool alone (see below).
tool_scope_map is a top-level list (same level as rules:; see POLICY_REFERENCE.md for why it's a list, not a map):
tool_scope_map:
- pattern: "fs:read_file"
scope: filesystem
- pattern: "fs:write_file"
scope: filesystem
- pattern: "fetch:*" # the fetch tool's `url` arg gives the network check a domain
scope: network
- pattern: "*:execute_*"
scope: shell
First match wins, in list order — put more specific patterns first.
A tool that matches no mapping is checked only under mcp_tool —
operators who want default-deny on unknown tools write:
rules:
- scope: mcp_tool
deny:
- pattern: "*:*" # belt-and-braces deny-all
allow:
- pattern: "fs:read_*" # explicit allowlist
4.5 Example policy (full)#
version: "1"
name: "mcp-gated"
rules:
# MCP-aware deny-list at the tool-name level
- scope: mcp_tool
deny:
- pattern: "github:delete_*"
- pattern: "fs:write_file"
conditions:
# The deny fires only after an fs:read_* was allowed in the
# last 5m: a read-then-write in one session is blocked. With no
# prior read the rule doesn't match, and the write falls
# through to the default DENY (nothing below allows it).
- require_prior: "fs:read_*"
time_window: "5m"
require_approval:
- pattern: "*:execute_*"
allow:
- pattern: "fs:read_*"
- pattern: "everything:*"
# Existing filesystem rules apply via the dual-check
- scope: filesystem
deny:
- action: read
paths: ["/etc/**", "/root/**", "/.ssh/**"]
- action: write
paths: ["/etc/**", "/root/**", "/.ssh/**"]
allow:
- action: read
paths: ["/tmp/**"]
- action: write
paths: ["/tmp/**"]
- scope: network
deny:
- domain: "169.254.169.254" # cloud metadata
allow:
- domain: "api.github.com"
tool_scope_map:
- pattern: "fs:read_file"
scope: filesystem
- pattern: "fs:write_file"
scope: filesystem
- pattern: "fetch:*" # the fetch tool's `url` arg gives the network check a domain
scope: network
5. Audit shape#
The gateway writes no audit log of its own. Each /v1/check call it
makes is audited by the central server like any other check, so the
entries land in the server's audit log with transport: "mcp_gateway".
The entries aren't merged: a tools/call in strict mode that maps to a
scope writes two entries, one for the mcp_tool check and one for
the mapped-scope check (one per path for a paths array). If the
mcp_tool check doesn't allow, the mapped check never runs and there is
one entry. A call in fast mode, or one that maps to no scope, writes
one entry.
A mapped fs:read_file of /tmp/x from Claude Desktop writes these two
entries:
{
"timestamp": "2026-05-06T12:34:56.789Z",
"session_id": "mcp-gateway:Claude Desktop",
"agent_id": "mcp-gateway:Claude Desktop",
"request": {
"schema_version": "v1",
"scope": "mcp_tool",
"command": "fs:read_file",
"agent_id": "mcp-gateway:Claude Desktop",
"session_id": "mcp-gateway:Claude Desktop",
"meta": {
"arg_path": "/tmp/x",
"gateway_connection": "3f9c0a1b2d4e5f60",
"namespace": "fs",
"tool_name": "read_file",
"transport": "mcp_gateway"
}
},
"result": {
"schema_version": "v1",
"decision": "ALLOW",
"reason": "Allowed by mcp_tool rule",
"matched_rule": "allow:mcp_tool:fs:*"
},
"duration_ms": 0,
"transport": "mcp_gateway"
}
{
"timestamp": "2026-05-06T12:34:56.790Z",
"session_id": "mcp-gateway:Claude Desktop",
"agent_id": "mcp-gateway:Claude Desktop",
"request": {
"schema_version": "v1",
"scope": "filesystem",
"action": "read",
"command": "fs:read_file",
"path": "/tmp/x",
"agent_id": "mcp-gateway:Claude Desktop",
"session_id": "mcp-gateway:Claude Desktop",
"meta": {
"arg_path": "/tmp/x",
"gateway_connection": "3f9c0a1b2d4e5f60",
"namespace": "fs",
"tool_name": "read_file",
"transport": "mcp_gateway"
}
},
"result": {
"schema_version": "v1",
"decision": "ALLOW",
"reason": "Allowed by filesystem rule",
"matched_rule": "allow:filesystem:read"
},
"duration_ms": 0,
"transport": "mcp_gateway"
}
Both entries carry the same meta: namespace, tool_name, transport,
gateway_connection, approval_id (on a retry that carries one) and one
arg_<key> per tool argument. A string argument is stored as is; any
other value is JSON-encoded. Only the mapped entry has path, url,
domain or action, projected from the arguments (§ 4.4).
The arg_<key> values are redacted by the central server before the
entry is written (vanzik serve --audit-redact, on by default), through
pkg/notify.DefaultRedactor plus the policy's
notifications.redaction.extra_patterns. Bearer tokens, AWS
keys, GitHub tokens, Slack tokens, key=value secret patterns,
sk-… and Google API keys, JWTs, PEM private keys and
Authorization / x-api-key header values are scrubbed.
session_id comes from the MCP client's name (mcp-gateway:<name>), so
two hosts running the same client share a session, and its
cost.max_per_session budget. To tell them apart, each
gateway process picks a random id at start and sends it on every check
as meta.gateway_connection; the session id is unchanged. The
dashboard's session replay shows it under each entry.
The dashboard renders the transport: "mcp_gateway" chip in the audit
feed (blue), distinct from sdk (green) and llm_api_proxy (purple).
6. Approval flow#
6.1 First call (no approval id)#
Host sends tools/call. Gateway runs policy check. Result =
REQUIRE_APPROVAL with approval_id="ap_<32hex>" and
approval_url="http://127.0.0.1:8080/v1/approve/ap_..." (the POST resolution endpoint; humans approve in the dashboard at /dashboard).
The gateway returns:
{
"jsonrpc": "2.0", "id": 3,
"result": {
"content": [{
"type": "text",
"text": "[Vanzik] Action requires approval.\nReason: <reason>\nApproval ID: ap_<hex>\nApprove at: http://127.0.0.1:8080/v1/approve/ap_<hex>"
}],
"isError": true
}
}
isError: true matches the spec for tool execution errors. The
language model sees this as a tool error and either retries (see § 6.2)
or surfaces the URL to the user.
6.2 Retry with approval id#
The MCP spec's _meta reservation makes this clean. After the operator
approves on the dashboard, the model retries the same tools/call with
_meta.dev.vanzik/approval_id populated:
{
"jsonrpc": "2.0", "id": 4, "method": "tools/call",
"params": {
"name": "fs:write_file",
"arguments": { "path": "/tmp/output.txt", "content": "..." },
"_meta": { "dev.vanzik/approval_id": "ap_<hex>" }
}
}
The round-trip is single-hop and stateless on the gateway — the central server owns the truth:
- The gateway sends
_meta["dev.vanzik/approval_id"]as the top-levelapproval_idof the/v1/checkbody, and also asmeta.approval_idso the audit entry shows it. - The central server, seeing a non-empty
approval_id, consults its approval queue before running policy:- resolved=true && decision=ALLOW → returns
decision: ALLOW, matched_rule: "allow:approved". The audit log records this as a separate entry taggedtransport: "mcp_gateway"so investigators can distinguish human-approved from policy-allowed traffic. - resolved=true && decision=DENY → returns
decision: DENY, matched_rule: "deny:approved". The bridge surfaces it as a tool error with the operator's reason. - resolved=false (still pending) → returns the same
approval_idandapproval_urlback, so the polling client keeps waiting rather than spawning a duplicate queue entry. - unknown id (typo / expired / wrong tenant) → falls through to normal policy evaluation. An attacker who guesses an id gains nothing; an honest caller with a stale id gets correct enforcement.
- resolved=true && decision=ALLOW → returns
The gateway never queries /v1/status/{id} directly on the retry —
that endpoint is reserved for the polling SDK clients. The dual-check
pattern (mcp_tool + mapped scope) preserves the approval id on both
calls so a resolved approval short-circuits whichever scope the
policy used to require it.
The reserved _meta prefix dev.vanzik/ follows the MCP
2025-11-25 _meta rules: reverse-DNS-style label, recommended
form. We do not use io.modelcontextprotocol/ or dev.mcp/ —
those are explicitly reserved for MCP itself.
6.3 Why client-supplied id (not in-process state)#
The gateway is single-process per MCP host, but the central
ApprovalQueue lives on the central server. If we keyed approvals by
in-process call-signature on the gateway, we'd drop the mapping every
time the gateway restarted. Echoing the id through _meta is
restart-safe and doesn't require synchronisation across multiple
gateway instances behind a load balancer.
7. Reconnect strategy#
A stdio upstream is degraded when its stdout reaches EOF or the process
exits. The gateway restarts it with this backoff (--reconnect-cap
sets the longest wait):
backoff := []time.Duration{
1 * time.Second,
2 * time.Second,
5 * time.Second,
30 * time.Second,
60 * time.Second,
}
// Capped at backoff[len-1] for subsequent retries.
While in degraded state:
tools/listaggregation excludes the namespace's tools (the client just doesn't see them — the model can't try to call them).tools/callto a degraded namespace returns the gateway's own error code-32002:{ "jsonrpc": "2.0", "id": ..., "error": { "code": -32002, "message": "tools/call: upstream \"<ns>\" unavailable", "data": { "namespace": "<ns>" } } }- The gateway has no health endpoint, and the central server's
/v1/healthknows nothing about the gateway's upstreams. The degradation shows only in the gateway's stderr log.
Recovery: when the subprocess restarts and re-completes initialize,
the next tools/list aggregation re-includes its tools. A
permanently-broken upstream stays degraded, retried at most once per
cap interval.
An --upstream-http server has no process to watch. It is degraded when
it cannot be reached: a connection failure or HTTP 502/503/504. A slow
reply that hits --upstream-timeout, or any other HTTP error, fails
only that call. While degraded, the same backoff schedule replays the
cached initialize until the server answers. The first initialize
counts in full: a server that is down, times out while it cold-starts,
or answers it with any HTTP error when the host connects is degraded
and joins the session once it answers. On recovery the gateway sends the
host notifications/tools/list_changed so it re-pulls the namespace's
tools.
8. Wire format details#
8.1 Stdio framing#
Per spec: newline-delimited UTF-8 JSON. No embedded newlines in any JSON-RPC message. The gateway:
- Caps an incoming frame at 4 MiB (tool argument JSON can legitimately
be large). An oversized frame is dropped, answered with
-32600when its id can be read, and the gateway keeps going. - Writes each frame to stdout whole, with a
\nterminator, under one lock, so concurrent responses never interleave.
8.2 JSON parse errors#
A malformed frame on the host's stdin does not crash the gateway:
for scanner.Scan() {
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 { continue }
var msg JSONRPCFrame
if err := json.Unmarshal(line, &msg); err != nil {
log.Printf("WARN dropping malformed frame: %v", err)
continue
}
g.dispatch(msg)
}
if err := scanner.Err(); err != nil {
// I/O failure — treat as host disconnect, run shutdown
}
Per the JSON-RPC 2.0 spec, we could emit a -32700 Parse error
response — but the malformed frame may not even have a parseable id, so
the gateway silently drops as the Python adapter does.
8.3 Concurrency model#
Each tools/call runs concurrently; other methods (initialize,
tools/list, ping) are handled in arrival order. Each upstream request
gets a gateway-internal id, which routes the upstream's response back to
the waiting call. An upstream's stderr goes to the gateway's log.
8.4 Cancellation#
When the host sends notifications/cancelled, the gateway broadcasts
the notification to every healthy upstream. This is currently a
best-effort no-op. The notification's params.requestId is in the
host's id space, but the gateway rewrites every request id to a
gateway-internal id before sending it upstream. A forwarded cancellation
therefore never matches an in-flight upstream request, and the upstream
cannot act on it. The gateway also does not cancel the goroutine
awaiting the response: the tools/call runs to completion (or
--upstream-timeout) upstream and its response is still delivered to the
host, which — having cancelled — may ignore it. No CANCELLED audit
entry is written.
Real cancellation would need a host↔upstream request-id map so the
gateway can translate requestId on the way out; it is not implemented.
8.5 Streamable HTTP upstreams#
--upstream-http servers use the MCP Streamable HTTP transport
(protocol revision 2025-06-18, basic/transports). The gateway:
- POSTs every JSON-RPC message to the one MCP endpoint, with
Content-Type: application/jsonandAccept: application/json, text/event-stream, plus the--upstream-headerheaders. - Reads a request's reply as either one JSON object or an SSE stream.
On the stream, notifications and server-initiated requests may come
before the response; they are handled as on stdio (only
notifications/tools/list_changedreaches the host, server requests are dropped). Lines may end in CRLF, LF or a lone CR, and a leading byte-order mark is skipped. Lines and events are capped at 4 MiB; an oversized event is dropped and the stream keeps going. - Keeps the
Mcp-Session-Idfrom theinitializeresponse and sends it, withMCP-Protocol-Version: <negotiated version>, on every later request. Notifications expect202 Accepted. - Treats HTTP 404 to a request that carried a session id as an expired
session: it starts a new session from the cached
initializeand retries the request once. A 404 means the server did not process the message, so the retry cannot run a tool call twice. If the new session cannot be started, the upstream is degraded (§ 7). - Opens the optional GET stream for notifications sent outside any
request; a
405turns it off. - Sends
DELETEwith the session id when the gateway shuts down. - Does not follow redirects (net/http would forward custom headers, and so tokens, to another host) and never logs header values or the URL.
9. Test strategy#
The tests live in pkg/mcpgw. The end-to-end ones (at_*_test.go) drive
a full session against a stub MCP server built from
pkg/mcpgw/testdata/stub_server, so they need no network; they run with
go test ./... and skip under -short.
10. Currently out of scope#
resources/*andprompts/*are not routed. The gateway dispatchesinitialize,tools/list,tools/call,ping, andlogging/setLevel; every other method — including allresources/*andprompts/*requests — gets-32601 MethodNotFound. Theresourcesandpromptscapabilities are also masked out of theinitializeresponse (§ 3.3) so a host never offers them. Tracked as#mcp-resources.- Server-initiated requests are not supported. MCP lets a server
open a request back to the client (
sampling/createMessage,roots/list,elicitation/create). The gateway is a tools-only firewall: such a frame from an upstream carries anid+method, matches no pending response, and is dropped (logged at Info). The host never sees it, so a downstream that depends on server-initiated flows will appear to hang on that feature while its tools keep working normally. notifications/cancelledis a best-effort no-op — the gateway cannot map the host-spacerequestIdonto the in-flight upstream request. See § 8.4.- Streamable HTTP on the host side. The host always talks to the
gateway over stdio. Upstreams can be stdio (
--upstream) or Streamable HTTP (--upstream-http, § 8.5). - Resuming a broken HTTP event stream. The gateway ignores SSE event
ids and never sends
Last-Event-ID. If a server drops a POST's event stream before the response, that call fails; the host can retry it. - The older HTTP+SSE transport (protocol
2024-11-05, separate SSE and POST endpoints).--upstream-httpspeaks Streamable HTTP only. - Per-upstream rate limiting. The central server's rate limiter
applies, but a misbehaving downstream that floods on
tools/listwill be felt.
11. Client integration#
Vanzik ships working configs for the five MCP clients with the
largest user bases. Each examples/<client>-config.json is the
authoritative copy-paste, and each has a sibling <client>-config.md
with the OS-specific config-file path, the source-doc URL, and a
verification checklist. The fastest end-to-end is
docs/QUICKSTART_MCP.md.
| Client | Example | Config-file path |
|---|---|---|
| Claude Desktop | examples/claude-desktop-config.json |
macOS ~/Library/Application Support/Claude/claude_desktop_config.json • Windows %APPDATA%\Claude\claude_desktop_config.json |
| Cursor | examples/cursor-config.json |
global ~/.cursor/mcp.json or per-project <workspace>/.cursor/mcp.json |
| Cline (VS Code) | examples/cline-config.json |
inside VS Code's globalStorage/saoudrizwan.claude-dev/settings/cline_mcp_settings.json (use the in-app Configure MCP Servers button) |
| Continue.dev | examples/continue-config.json |
~/.continue/config.json (legacy) or <workspace>/.continue/mcpServers/*.yaml |
| Zed | examples/zed-config.json |
~/.config/zed/settings.json — top-level key is context_servers, NOT mcpServers |
11.1 Minimal Claude Desktop config#
Copy examples/claude-desktop-config.json
(the authoritative block, also walked through step-by-step in
QUICKSTART_MCP.md) into the config-file path from
the table above.
The gateway namespaces tools per upstream (fs:read_text_file,
github:list_issues, …), so policies written against namespaced names
work without changes. Strict policy mode (the default) requires
--policy <path> because the gateway resolves the
tool_scope_map locally to drive the dual-check (mcp_tool + mapped
scope) — see § 4.4.3 above.
11.2 Verifying the integration#
Once the config is saved and the client restarted (or, for Cline / Continue, reloaded — they pick up changes live):
- The MCP indicator appears. Claude Desktop shows a small slider in
the chat input; Cursor's MCP panel lists
vanzik; Cline's MCP-Servers tab shows green; Continue's tools list populates in agent mode; Zed's Assistant shows the merged tools. - Tools/list aggregation works. Ask the model "what tools do you have?" — every tool from every upstream should be present, prefixed with the namespace.
- An ALLOW shows on the dashboard. Ask the model to perform a
benign action (read a file from
/tmp, fetchhttps://example.com). The dashboard at http://127.0.0.1:8080/dashboard shows anALLOWevent withtransport=mcp_gatewayand the namespaced tool name. - A DENY shows on the dashboard. Ask the model to read
/etc/passwd. The dashboard shows aDENYevent; the model reports the tool errored with the policy reason embedded.
If actions never reach Vanzik, the gateway is probably failing to
launch — see § 11.3 below. If actions reach the gateway but never reach
Vanzik's /v1/check, check that --guard-url is reachable from the
gateway's process (loopback issues on Docker bridge networks bite here).
11.3 Common gotchas#
Binary not on PATH. Claude Desktop / Cursor / Zed / Cline launched
from the Finder (macOS) or the Start menu (Windows) inherit a sparse
PATH that does not include $(go env GOPATH)/bin. If which vanzik-mcp-gateway works in your terminal but the MCP indicator
never appears, replace "command": "vanzik-mcp-gateway" with the
absolute path to the binary.
Shell-var expansion in flag strings. None of the supported clients
expand $VANZIK_API_KEY or $VAR inside flag-string arguments.
The recommended pattern is:
- Set the variable in the
envblock of the server entry (which is forwarded to the subprocess). - Reference it from your launcher script or secret manager — never paste raw keys into the JSON.
- Cursor additionally supports
${env:NAME}interpolation inargs(verified against Cursor docs 2026-05-05); the other clients do not.
Stale tool list after policy edit. Adding or removing tools from a
policy does not require a gateway restart — the gateway re-checks every
call against the central server, which reloads its policy file on its own
(--watch only logs each reload). But
adding a new upstream (a new --upstream flag) does require a
gateway restart, which means restarting the MCP client.
Missing launcher (npx, uvx, docker). The bundled examples start
the filesystem server with npx, the fetch server with uvx, and
GitHub's MCP server with docker. If a launcher isn't on PATH inside
the MCP client's environment (a notoriously common Windows issue for
npx), that upstream fails to spawn and the gateway logs
info mcpgw: startup: upstream "<ns>" failed to spawn: … to stderr —
visible in Claude Desktop's mcp.log and equivalents — while the other
upstreams keep working. Check the launcher on its own (npx --version,
uvx --version, docker version) before debugging deeper.
Cookie-based auth on macOS. Vanzik's session cookies depend on
the connection's TLS state. When the central server runs on plain HTTP
on 127.0.0.1 (the dev default) and Claude Desktop talks to it from
the gateway, the API key is sent as Authorization: Bearer … — no
cookies are involved, so this is fine. The cookie path only matters if
you log in to the dashboard from a browser, in which case use
--allowed-origin and (behind a TLS-terminating proxy)
--tls-terminated-upstream.
--api-key is auth for /v1/check, not for the gateway itself.
The gateway is launched as a subprocess by the MCP client and trusts
its parent — there is no inbound HTTP for the gateway. The
--api-key flag is only used for the gateway's outbound
/v1/check calls to the central Vanzik server.
12. References#
- MCP spec, current revision (verified 2026-05-06):
- MCP error codes: JSON-RPC 2.0 reserved range (-32768 to -32000); MCP
uses
-32602for invalid params,-32603for internal errors,-32700for parse errors. Denial and approval-required are returned as tool execution errors (isError: true), not JSON-RPC protocol errors. The gateway defines three codes in the server-defined range (pkg/mcpgw/protocol.go):-32002for an unavailable upstream, which it returns (§ 7), and-32000(policy deny) and-32001(approval required), which are reserved and not returned today. - Python preview:
plugins/python/vanzik/adapters/mcp.py(GuardedMCPGateway). - Cross-cutting design:
docs/PROXY_ARCHITECTURE.md.