Skip to main content
A protocol: mcp lane terminates MCP streamable HTTP on /mcp and forwards to one remote MCP server over streamable-http or sse. It hides the tools a rule denies, judges each tools/call with the lane’s guardrails and OPA, and records one Sidecar session per MCP session. The lane embeds mcpproxy and ships in Sidecar 1.289.0 and later. A Sidecar build without the MCP engine refuses the lane at -validate.

Configure a lane

config.yaml
The MCP client points at http://127.0.0.1:18901/mcp.

Listener keys


Tool allow and deny

A guardrail rule of type mcp_tool decides which tools a lane offers. tools lists the tools it denies; allow lists the only tools it lets through. A rule sets one of the two.
config.yaml
The lane leaves out of tools/list every tool an mcp_tool rule denies, so the model never sees it. The catalog statement names the hidden tools in mcp.tools_hidden. A call to a hidden tool is denied like any rule denial: a tool result with isError: true and the text denied by the hoop sidecar (rule <name>): <message>. MCP reports a failed tool call this way, as a result rather than a JSON-RPC error, so the model sees the denial and its message. A tools/list result the lane cannot read ends the session and records mcp_catalog_unreadable: the lane could neither hide tools from it nor judge it. With action: defer, an mcp_tool rule hides nothing, and OPA decides each call: without OPA, a deferring rule denies the call. Two allow rules let through only the tools both list. A tools/call is judged by its exact name key. Params that name no tool, or carry keys that differ only in case, are refused as invalid params (-32602) and recorded as mcp_invalid_params. Other rule types and OPA judge calls too, and hide nothing. A glob has one metacharacter: * matches any run of characters, including none. Every other character matches literally, and case counts: *delete* matches bulk_delete_rows, and get_* does not match Get_file. The rule reads the real tool name, not the name overrides gives it. An mcp_tool rule in the top-level guardrails reaches every mcp lane, and other lanes ignore it. A lane’s own rules run first and the top-level ones after them; rules: [] on the lane drops the top-level ones. A rule edit applies without a restart and ends the open MCP sessions; the next session gets the new catalog. The rules screen of the control plane does not offer mcp_tool yet: write it in the config file.

mcp_gateway keys

tool_rate_limits globs work as in mcp_tool. Once the session has listed tools, a call to a tool the server did not list is refused too: -32010, tool "x" was not advertised by the server. A call these keys refuse, a truncated result and a rug pull reach the Sidecar session as activities: mcp.tool_denied, mcp.budget_exceeded, mcp.tools_changed. A key the block does not map, such as allowed_tools (an mcp_tool rule instead), approval_tools, command or headers, fails as unknown field.

What the rules read

Each tools/call is one statement: operation: call, tables: [<tool>] in lower case, the text <tool> with its arguments JSON on the next line, and the metadata mcp.method, mcp.tool and mcp.session.
config.yaml
The metadata names the tool in the audit trail and in OPA’s input. Each tools/list is one catalog statement: direction: server, operation: show, no tables, one <name>: <description> line per tool. Rules and OPA judge the catalog too, and a hit ends the session. A deny_words_list word in a tool description ends it. A table rule never matches the catalog, require_table_match included. Policy Rules describes each rule type.

OPA

With an opa block, the lane consults OPA on every call and on the catalog. The input for a call, beside the context every lane sends:
responses: false keeps the catalog from OPA:
config.yaml

Sessions and audit

One Sidecar session per MCP session. It opens on the first message and closes on the client’s DELETE or the idle timeout. The session records initialize as an activity (client name, version, protocol version), one statement per tools/call, allowed or denied with its rule, and one statement per tools/list. mcpproxy refusals, such as a budget or a rug pull, reach the session as activities. A rule reload ends the open MCP sessions. The next message on one is refused, and the client initializes again under the new rules. A change to the lane itself (upstream, listen, the mcp_gateway block) needs a restart, as for every listener.

What the audit records

Each statement carries this metadata: The session also records these activities: A call a guardrail rule or OPA denies is a statement with its rule, not an activity.

Auth

The lane can check a caller token. It sends no credential to the upstream except a mutual TLS certificate.

Caller to lane

identity_header names a caller and verifies nothing: any process that reaches the port can set it. Use it only behind a proxy on the same host or pod that authenticates the caller and sets the header. Callers the lane cannot tell apart share the anonymous identity, so any of them that holds a session id can use that session. Not supported yet: a verified identity per user, and OAuth login of MCP clients.

Caller token

caller_token admits a caller by a shared token, so the lane can listen on an address other than loopback. Sidecar 1.290.0 and later; older builds refuse the key at -validate.
config.yaml
The MCP client sends the token on every request:
  • What is refused. No header, an empty value, a value not in the file, or the header sent twice. The request gets a 403 request refused: <reason> before the lane reads its body, and the connection closes. Claude Code shows the server as needs-auth.
  • 403, not 401. A 401 sends MCP clients into OAuth discovery, which the lane does not serve.
  • Refusals are logged, not audited. No MCP session exists yet. The Sidecar logs mcp request refused with the peer address and the reason.
  • What is admitted. The session and every statement name name as the principal.
  • A session belongs to its token. An Mcp-Session-Id works only with the token that opened it. A request with another token gets a 404, and the session records mcp.session_refused.
  • The token stays in the Sidecar. The lane removes the header first. The token never reaches the upstream, an audit row, policy input or a log.
  • Refused at startup. A header that MCP uses (Mcp-*, Last-Event-ID), or one the upstream, HTTP framing or the Sidecar already uses, such as authorization or host. caller_token together with identity_header.
  • No TLS of its own. The token travels as written. Set downstream_tls, or put TLS in front of the listener.
The file, rotation and revocation work as on the http lane. See Caller token.

Lane to upstream

The lane sends no Authorization header to the upstream, the caller’s included. It refuses headers, because the config holds no inline secret. For a server that needs a bearer token or OAuth: run the MCP server yourself with its credential, or run a proxy on loopback that adds the credential, and point upstream at that proxy (http://127.0.0.1:<port>/mcp is accepted on loopback). Not supported yet: a token from a file or a secret source and the caller’s own token, OAuth to the upstream, and AWS SigV4.

Limits

  • listen takes an address other than loopback only with caller_token. See Caller token.
  • No masking yet. Mask rules inherited from the top level are accepted, and -validate notes that they do not apply. A mask block on the lane is refused.
  • No human review yet: an analyzer block is refused, and no call can be held for approval.
  • max_conns and network: unix are refused.
  • Sessions live in memory: run one replica, or pin Mcp-Session-Id to one.

Try it

The envoy-stack/mcp demo runs one session through a lane in front of a test MCP server: a request without the caller token, refused with 403, then initialize, tools/list without the tools an mcp_tool rule hides, an allowed call, a call to a hidden tool and a call over the mcp_gateway budget, then the audit rows under the token’s name.

Next

Policy Rules

Every rule type, and how a lane inherits rules.

Config File

The whole configuration, including the OPA block.