xin

MCP gateway

Route Model Context Protocol traffic the same way you route everything else: a location, an mcp_pass, and the policy directives that already exist for HTTP apply on top.

Current spec, plus one revision of compatibility. xin parses the envelope for MCP's 2026-07-28 stateless revision (the primary target — no session needed) and the 2025-11-25 revision'sMcp-Session-Id sticky sessions, and passes protocol version negotiation through rather than enforcing a single revision.

Why a location is enough

Every MCP operation over Streamable HTTP is POST /mcp — there is no URL-keyed vocabulary for a plain reverse proxy to hook policy into. xin's gateway parses the JSON-RPC envelope itself, so the directives you already use — limit_req,access_log, allow/deny, TLS — sit next to two new tool-aware ones inside the same location.

Three shapes

One server, one location

Distinct MCP servers are distinct locations, exactly like distinct HTTP backends:

location /mcp/search {
    mcp_pass http://search-backend:8080/mcp;
}

location /mcp/files {
    mcp_pass stdio:/usr/local/bin/fs-mcp-server --root=/srv/docs;
}

The second location is the fastcgi move: a local tool server that only speaks stdio JSON-RPC becomes an ordinary HTTP endpoint, on the same listener, TLS and logging as everything else.

Replicas of one server

Load-balance across replicas with an ordinary upstream block — session affinity for older, session-based clients rides on top of the normal balancer:

upstream mcp_search {
    server 10.0.0.10:8080;
    server 10.0.0.11:8080;
}

location /mcp/search {
    mcp_pass http://mcp_search;
}

Policy and rate limits, in nginx idiom

log_format mcp_audit '$time_iso8601 $remote_addr $mcp_method $mcp_tool';
limit_req_zone $binary_remote_addr zone=mcp_clients:10m rate=20r/s;

server {
    listen 443 ssl;
    server_name mcp.example.com;
    ssl_certificate     /etc/xin/tls/fullchain.pem;
    ssl_certificate_key /etc/xin/tls/privkey.pem;

    location /mcp {
        mcp_pass http://search-backend:8080/mcp;

        mcp_allow_tool search.* docs.read_*;
        mcp_deny_tool  docs.delete_* docs.write_*;

        limit_req zone=mcp_clients burst=20 nodelay;
        access_log /var/log/xin/mcp-audit.log mcp_audit;
    }
}

One TLS-terminated, tool-policed, rate-limited, audit-logged MCP endpoint — in the config shape any nginx operator already reads fluently, with no new concepts beyond the mcp_* family itself.

Header/body integrity

The 2026-07-28 revision carries Mcp-Method andMcp-Name headers as a fast path for routing and policy. xin reads them, but cross-checks them against the JSON-RPC body whenever it parses one — a mismatch is a 400. Policy keyed only on a header a client can set is policy a forged header can spoof; xin's tool allow/deny can't be walked around that way.

Directive reference

mcp_pass

mcp_pass http://upstream_or_host[:port]; | mcp_pass stdio:<command line>;

Routes a location's MCP traffic to a backend. The http:// form works exactly like proxy_pass — a literal host:port or a named upstream block, with the normal load-balancing directives riding on top. The stdio: form spawns the given command as a child process (one per worker, not per session), speaks newline-delimited JSON-RPC over its stdin/stdout, and supervises it with respawn-on-exit. Its stderr lands in error_log at info.

Context: location

mcp_allow_tool

mcp_allow_tool name ...;

Allows the named tools on tools/call. Names support a glob (*). Default is allow-all until a deny rule narrows it. Evaluated against the JSON-RPC call arguments, not the URL — there is no URL to match against, every MCP call is the same route.

Context: location

mcp_deny_tool

mcp_deny_tool name ...;

Denies the named tools; deny always wins over allow. A denied call gets a JSON-RPC error response over HTTP 200 — MCP errors travel in-band, so the refusal is a normal, spec-shaped answer, not a broken connection.

Context: location

mcp_env

mcp_env KEY=VALUE;

Adds one variable to a stdio: child's environment, on top of the inherited environment minus the names xin already strips for privilege-drop. The child always runs as the serving user, never as root.

Context: location

mcp_stdio_sessions

mcp_stdio_sessions single | shared;

Default: mcp_stdio_sessions shared;

Controls how concurrent HTTP clients share one stdio child. shared (default) serializes their requests onto the one logical session; single refuses a second concurrent client outright.

Context: location

Variables

VariableValue
$mcp_methodThe JSON-RPC method of the current call (tools/call, tools/list, initialize, ...), for log_format, map and limit_req keys.
$mcp_toolThe tool name argument of a tools/call — the value mcp_allow_tool/mcp_deny_tool match against, and a natural rate-limit or log key.