Skip to content
browse the docs

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:

  1. Host sends initialize to 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" }
      }
    }
    
  2. The gateway picks the highest version in SupportedProtocolVersions that 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" }
      }
    }
    
  3. The gateway forwards initialize to 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.

  4. The gateway returns its own initialize response 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 serverInfo always identifies it as vanzik-mcp-gateway.

  5. Host sends the notifications/initialized notification (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-less tools/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.list from 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:

  1. Splits the name on the first :. Left = namespace; right = upstream tool name.
  2. If no : is present or the namespace doesn't match a configured upstream: return -32602 Invalid params: unknown namespace.
  3. Runs policy check (see § 4.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" } } }
    
  5. 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 in meta["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_file with path: "/etc/passwd" → also check scope: "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_files with paths: ["/tmp/a", "/etc/passwd"] → one filesystem check per entry of the paths array.
    • github:fetch_url with url: "https://api.github.com/..." → also check scope: "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:

  1. The gateway sends _meta["dev.vanzik/approval_id"] as the top-level approval_id of the /v1/check body, and also as meta.approval_id so the audit entry shows it.
  2. 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 tagged transport: "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_id and approval_url back, 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.

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/list aggregation excludes the namespace's tools (the client just doesn't see them — the model can't try to call them).
  • tools/call to 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/health knows 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 -32600 when its id can be read, and the gateway keeps going.
  • Writes each frame to stdout whole, with a \n terminator, 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/json and Accept: application/json, text/event-stream, plus the --upstream-header headers.
  • 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_changed reaches 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-Id from the initialize response and sends it, with MCP-Protocol-Version: <negotiated version>, on every later request. Notifications expect 202 Accepted.
  • Treats HTTP 404 to a request that carried a session id as an expired session: it starts a new session from the cached initialize and 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 405 turns it off.
  • Sends DELETE with 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/* and prompts/* are not routed. The gateway dispatches initialize, tools/list, tools/call, ping, and logging/setLevel; every other method — including all resources/* and prompts/* requests — gets -32601 MethodNotFound. The resources and prompts capabilities are also masked out of the initialize response (§ 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 an id + 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/cancelled is a best-effort no-op — the gateway cannot map the host-space requestId onto 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-http speaks Streamable HTTP only.
  • Per-upstream rate limiting. The central server's rate limiter applies, but a misbehaving downstream that floods on tools/list will 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):

  1. 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.
  2. Tools/list aggregation works. Ask the model "what tools do you have?" — every tool from every upstream should be present, prefixed with the namespace.
  3. An ALLOW shows on the dashboard. Ask the model to perform a benign action (read a file from /tmp, fetch https://example.com). The dashboard at http://127.0.0.1:8080/dashboard shows an ALLOW event with transport=mcp_gateway and the namespaced tool name.
  4. A DENY shows on the dashboard. Ask the model to read /etc/passwd. The dashboard shows a DENY event; 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 env block 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 in args (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#