MastertheMesh
MCP · agentgateway · protocol
Reference

How MCP traffic flows, from one client to agentgateway

TO
Tom O'Rourke
Solo.io

Read an MCP exchange off the wire and know exactly what agentgateway does to it. This walks the four-phase JSON-RPC exchange between a client like Claude Code and a single MCP server, why the Mcp-Session-Id header makes MCP stateful, how agentgateway multiplexes many servers behind one client session and trims the tool list with CEL policy as both an ingress gateway and an ambient waypoint, how toolMode: Search collapses a large catalogue to get_tool and invoke_tool, and where the 2026-07-28 release candidate takes the protocol next: stateless, sessionless MCP plus Tasks for long-running work. The diagrams carry it; the prose labels them.

JSON-RPC 2.0 Mcp-Session-Id multiplexing CEL tool-access ambient waypoint toolMode: Search 2026-07-28 RC Tasks

What you'll learn

By the end you can read an MCP exchange off the wire and work out how a gateway in front of it will behave.

A client talking to one MCP server

MCP is a client and server protocol carried over JSON-RPC 2.0. There are three roles. The host is the AI application that owns the model and the conversation, for example Claude Code or an IDE. The host spins up one client per server, and each client holds a single channel to one server. The server exposes capabilities (tools, resources and prompts) and owns the connection to whatever real system sits behind it. Tools are the part most people mean when they say MCP: functions the model can call, advertised with a name, a description and an input schema.

Two transports matter.

The exchange, four phases

The lifecycle is negotiate, confirm, discover, call. Every message is a JSON-RPC object. The client sends initialize with the protocol version it proposes and the capabilities it supports. The server replies with the version it will actually use and what it offers. The client confirms with a one-way notifications/initialized (no id, no reply). Then it asks tools/list to discover what is there, the model picks a tool, and the client sends tools/call with the tool name and arguments. The diagram below is one full round.

HOST Claude Code CLIENT one per server SERVER tools + data user prompt initialize (version, capabilities) result + Mcp-Session-Id notifications/initialized tools/list tools[]: name + inputSchema tools/call (echoes session id) content + structuredContent answer carries the session

One client, one server, one round trip. The server mints the session id at initialize; the client echoes it on every later call.

A couple of fields are worth pulling out. In the tools/list reply, each tool carries a description and an inputSchema. The description is the text the model reasons over to decide whether to call the tool, so it is effectively prompt content. The input schema is the per-tool argument surface the model fills in. Both get injected into the model's context. Hold that thought, because it is the whole reason progressive disclosure exists further down.

The messages, field by field

Here is each message in full, with what every field is doing. Open the ones you care about. They all share the same envelope: jsonrpc is always "2.0"; id correlates a reply to its request (a notification has no id and expects no reply); method is the operation, namespaced with a slash and case-sensitive; params carries the arguments.

1
initialize negotiate · capability handshake
request · client → server
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "roots": { "listChanged": true },
      "sampling": {},
      "elicitation": {}
    },
    "clientInfo": { "name": "solo-agent-runtime", "version": "0.4.2" }
  }
}
protocolVersionThe MCP version the client proposes. The server replies with the version it will actually use, and may down-negotiate to a lower common one.
capabilitiesThe host-side primitives the server is allowed to rely on.
roots.listChangedThe client will send a notification if its set of filesystem or URI roots changes.
samplingAn empty object is the signal that the client lets the server request model completions back through it. No sub-fields needed.
elicitationThe client can surface server-initiated prompts to the user mid-session.
clientInfoHuman-readable client name and version, used in logs and UIs.
result · server → client
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {
      "tools": { "listChanged": true },
      "resources": { "subscribe": true, "listChanged": true },
      "prompts": {}
    },
    "serverInfo": { "name": "cluster-ops-server", "version": "2.1.0" },
    "instructions": "Use get_cluster_status before any mutating call."
  }
}
protocolVersionThe version the server has agreed to. If it cannot meet the client's proposal this is the lower common version, and the client decides whether to proceed.
capabilitiesWhat the server offers, and whether each primitive emits change notifications.
tools.listChangedThe server will notify the client when tools are added or removed at runtime.
resources.subscribeThe client may subscribe to updates on individual resources.
resources.listChangedThe server will notify when the resource list changes.
promptsAn empty object means prompts are supported with no optional sub-features declared.
serverInfoServer name and version.
instructionsOptional free text the host can fold into the system prompt to steer tool use.
2
notifications/initialized confirm · client → server
{ "jsonrpc": "2.0", "method": "notifications/initialized" }
(no id)A one-way notification with no id and no response. It tells the server the handshake is done and the session is live. Until it lands, the server should not send requests.
3
tools/list discover
request · client → server
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
params.cursorOptional. An opaque pagination token from a previous response's nextCursor. Omit it on the first page.
result · server → client
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "get_cluster_status",
        "title": "Get cluster status",
        "description": "Return health and version of a Kubernetes cluster",
        "inputSchema": {
          "type": "object",
          "properties": {
            "cluster": { "type": "string", "description": "Cluster name" }
          },
          "required": ["cluster"]
        },
        "outputSchema": {
          "type": "object",
          "properties": {
            "healthy": { "type": "boolean" },
            "istioVersion": { "type": "string" },
            "nodesReady": { "type": "integer" }
          }
        },
        "annotations": { "readOnlyHint": true, "idempotentHint": true }
      }
    ],
    "nextCursor": null
  }
}
tools[]The array of tool definitions. This is what the host injects into the model's context.
nameUnique programmatic id. This exact string is what the model emits to call the tool.
titleOptional human display name. Distinct from name, which stays machine-stable.
descriptionThe natural-language text the model reasons over to decide whether to call the tool. Effectively prompt content, so quality matters.
inputSchemaJSON Schema for the arguments. type is always object, properties defines each argument, required lists the mandatory ones. This is the per-tool argument surface.
outputSchemaOptional. JSON Schema for the structured result. If present, the server returns conforming structuredContent.
annotationsOptional behavioural hints: readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Advisory only, not enforced, and untrusted unless the server is.
nextCursorPagination token for the next page, or null when the list is complete.
4
tools/call call
request · client → server
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_cluster_status",
    "arguments": { "cluster": "ambient-prod-1" }
  }
}
params.nameThe name of the tool to run, exactly as advertised in tools/list.
params.argumentsThe argument values, which must satisfy that tool's inputSchema. The server validates before executing.
result · server → client
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [
      { "type": "text", "text": "ambient-prod-1: healthy, istio 1.24, 18 nodes ready" }
    ],
    "structuredContent": {
      "cluster": "ambient-prod-1",
      "healthy": true,
      "istioVersion": "1.24",
      "nodesReady": 18
    },
    "isError": false
  }
}
content[]Ordered result blocks the model reads. Each has a type: text, image, audio, resource (embedded) or resource_link.
content[].textFor a text block, the model-readable string.
structuredContentOptional machine-readable JSON conforming to outputSchema. A tool that returns this should also serialise it into a text block for backward compatibility.
isErrortrue means the tool itself failed (bad input, downstream error). It is a normal result, not a JSON-RPC error, so the model sees the failure and can reason about it.

Errors travel on two channels. A tool that fails comes back as a normal result with isError: true, so the model sees it and can adapt. A failure at the protocol level, like a malformed or unroutable request, replaces result with an error object.

error protocol-level failure · server → client
{ "jsonrpc": "2.0", "id": 3, "error": { "code": -32602, "message": "Invalid params: cluster is required" } }
code-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal error, -32000 to -32099 server-defined.
messageHuman-readable explanation of what went wrong.

MCP holds state

The part people miss is that MCP is stateful. When the transport is Streamable HTTP, the server can send back an Mcp-Session-Id header during the initialize phase, and the client puts that header on every request after it. That one session ties the whole exchange together: the capabilities that were negotiated, what has been discovered, anything the server is tracking for that client.

This matters the moment you run more than one copy of something in the path. If a client's first request lands on one instance and creates a session there, a later request that gets load-balanced to a different instance hits an instance that has never heard of that session. agentgateway handles this by exposing its Streamable HTTP endpoints as stateful by default: it returns the session id and pins follow-up requests for that session back to the same instance. You can turn that off with sessionRouting: Stateless on the backend when the upstream MCP servers do not need a session. Keep the statefulness in mind, because the release candidate at the end of this page sets out to remove it.

Multiplexing behind agentgateway

Put agentgateway between the client and the servers and the same four-phase exchange still happens, but the gateway becomes an MCP peer on both sides. The client thinks it is talking to one server. Behind the gateway there can be many.

How the sessions split

The client opens one MCP session to a single gateway endpoint, for example /mcp. The gateway opens its own session to each federated backend, so one client session maps to N backend sessions. When the client sends tools/list, the gateway fans the call out to every backend, merges the results into one list, and prefixes each tool name with the server it came from ({server}_{tool}) so two servers can both expose a search tool without colliding. On tools/call the gateway strips the prefix, works out which backend session owns the tool, and routes there. The client never learns there was more than one server.

MCP CLIENT one session /mcp AGENTGATEWAY Aggregate tools/list fan out, merge into one list Namespace {server}_{tool} RBAC filter CEL on jwt.* + mcp.tool.name one client session N backend sessions MCP SERVER A github MCP SERVER B cluster-ops MCP SERVER C everything tools/list session A session B session C

One client session in, a session per backend out. Aggregate, namespace, then filter, before the client sees anything.

Cutting the tool list down with policy

The filter step is where most of the value is. agentgateway runs a CEL-based tool-access policy that reads claims from the caller's validated JWT (jwt.<claim>) and the tool name (mcp.tool.name). It decides which tools a given caller is allowed to see, and it trims the aggregated list down to that subset before the response goes back. Tools the caller is not authorised to use are filtered straight out of tools/list, so the model never has them in its context in the first place. The check runs again at tools/call: even if a caller guesses a tool name, the gateway rejects the call before it reaches the upstream server. So the enforcement happens twice, at discovery and at execution.

The practical effect is that two callers hitting the same /mcp endpoint can get two different catalogues out of the same set of backends, decided by their identity, with nothing configured on the client. Add a tool to a backend and it shows up automatically for the callers whose policy allows it. There is a worked example of the JWT plus CEL allow-list pattern, with personas and curl probes, in the MCP controls field guide.

With and without an ambient mesh

Take one cluster: a client and an MCP server. agentgateway does the same MCP multiplexing and the same CEL tool-access either way. What Istio ambient changes is how the traffic gets to it.

WITHOUT ISTIO AMBIENT cluster CLIENT agent AGENTGATEWAY proxy policy bound here MCP server client points straight at the gateway WITH ISTIO AMBIENT cluster · ambient mesh AccessPolicy CLIENT agent AGENTGATEWAY waypoint (L7) fronts the server MCP server ztunnel (L4) redirects through the waypoint, transparently Same agentgateway, same CEL tool-access. Ambient adds ztunnel and a transparent waypoint, so the client does not change.

The same cluster, client and MCP server in both. Istio ambient is the only difference: ztunnel redirects the client through an agentgateway waypoint for Layer 7, where the AccessPolicy is enforced.

Progressive disclosure

Back to that thing I flagged earlier. Every tool in tools/list carries its full description and input schema, and the host injects all of it into the model's context on every interaction. For a big server, or several servers federated behind one endpoint, that is a lot of tokens spent before anyone has asked a question. It is pure overhead and it costs money on every call.

Three tool modes

agentgateway lets you choose the shape a backend presents, with entMcp.toolMode on an EnterpriseAgentgatewayBackend. There are three modes.

toolModeWhat the client sees on tools/listWhen to use it
StandardEvery upstream tool, filtered by RBAC. The full catalogue with every input schema.A small, stable tool set that fits comfortably in context.
SearchTwo meta-tools only: get_tool and invoke_tool.Many tools, or several servers, where the full list would crowd the context window.
CodeOne run_code tool. The model submits JavaScript that calls the authorised tools in a sandbox.Multi-step workflows that would otherwise be several tool calls passing results back through context.

The mode is one field on the backend. Here is the EnterpriseAgentgatewayBackend shape for each, the same upstream target throughout so only the mode differs.

apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata:
  name: mcp-backend
spec:
  entMcp:
    toolMode: Standard        # the default; omit toolMode and you get this
    targets:
      - name: fetcher
        static:
          host: mcp-website-fetcher.default.svc.cluster.local
          port: 80
          protocol: SSE
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata:
  name: mcp-backend
spec:
  entMcp:
    toolMode: Search          # expose only get_tool + invoke_tool
    targets:
      - name: fetcher
        static:
          host: mcp-website-fetcher.default.svc.cluster.local
          port: 80
          protocol: SSE
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayBackend
metadata:
  name: mcp-backend
spec:
  entMcp:
    toolMode: Code            # expose one run_code tool
    codeMode:                 # only valid when toolMode is Code
      timeout: 7s
    targets:
      - name: fetcher
        static:
          host: mcp-website-fetcher.default.svc.cluster.local
          port: 80
          protocol: SSE

targets is the same in all three; the only field that changes is toolMode, plus codeMode which is rejected at admission time unless toolMode is Code.

Search mode is the one people mean by progressive disclosure. Instead of the real tools, the gateway exposes two. get_tool lets the model look a tool up by name or description fragment and pull back the schema for that one tool, on demand. invoke_tool runs the tool the model chose. The params are not gone, they are fetched lazily, one tool at a time, only when the model decides it wants that one. The full catalogue still lives behind the gateway. The client just does not pay for it up front. RBAC still applies: a tool the caller is not allowed to use is hidden from get_tool results, the same way it is filtered from a Standard list.

STANDARD MODE model context window full tool list every description + inputSchema 10,877 prompt tokens SEARCH MODE model context window get_tool + invoke_tool rest stays free for the actual conversation 970 prompt tokens 91.1% fewer prompt tokens, same GitHub MCP server, identical prompt

Solo's own measurement against the GitHub MCP server: 10,877 prompt tokens in Standard mode, 970 in Search mode.

What Search mode looks like on the wire

Here are the actual payloads, using a backend that fronts a single upstream fetch tool. The thing to notice in the first one: the only information the model gets up front is the get_tool description, and that description carries the index of tool names (Available tools: [fetch]) but none of their input schemas. The schema for a tool only arrives when the model asks for it by name.

1
tools/list what the client sees · Search mode
result · server → client
{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {
    "tools": [
      {
        "name": "get_tool",
        "description": "Get information about available tools. Use 'name' for exact lookup by tool name. Hits return {\"results\": [...]}. Lookup misses return {\"results\": [], \"status\": \"no_match\", \"available_tools\": [...]}. Available tools: [fetch]",
        "inputSchema": {
          "type": "object",
          "properties": {
            "name": { "type": "string", "description": "Exact tool name. Use a name you have already seen from this server in the current session." }
          },
          "required": ["name"]
        },
        "outputSchema": {
          "type": "object",
          "properties": {
            "results": {
              "type": "array",
              "items": {
                "type": "object",
                "properties": {
                  "name":             { "type": "string" },
                  "tool_description": { "type": "string" },
                  "args":             { "type": "object", "description": "The input argument schema for the tool." }
                },
                "required": ["name", "tool_description", "args"]
              }
            },
            "status":          { "type": "string", "description": "no_match means the lookup succeeded but found no tool." },
            "available_tools": { "type": "array", "items": { "type": "string" } }
          },
          "required": ["results"]
        }
      },
      {
        "name": "invoke_tool",
        "description": "Invoke a tool by name with the given arguments. Use get_tool first to discover available tools and their required arguments.",
        "inputSchema": {
          "type": "object",
          "properties": {
            "name":      { "type": "string", "description": "The name of the tool to invoke." },
            "arguments": { "type": "object", "description": "The arguments to pass to the tool." }
          },
          "required": ["name"]
        }
      }
    ]
  }
}
get_toolThe lookup tool. Its description lists the available tool names so the model knows what exists, but returns no per-tool schema. inputSchema takes a single name.
get_tool.outputSchemaWhat a lookup returns: results[] with each tool's name, tool_description and args (its input schema). A miss returns an empty results, a no_match status, and available_tools to guide the next lookup.
invoke_toolThe execution tool. Takes the chosen tool name and an arguments object that holds the upstream tool's own input.
2
get_tool look up one tool
request · client → server
{
  "jsonrpc": "2.0",
  "id": 3,
  "method": "tools/call",
  "params": {
    "name": "get_tool",
    "arguments": { "name": "fetch" }
  }
}
result · server → client
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "structuredContent": {
      "results": [
        {
          "name": "fetch",
          "tool_description": "Fetches a website and returns its content",
          "args": {
            "type": "object",
            "required": ["url"],
            "properties": {
              "url": { "type": "string", "description": "URL to fetch" }
            }
          }
        }
      ]
    },
    "isError": false
  }
}
params.nameThe meta-tool being called, get_tool.
params.arguments.nameThe tool to look up, fetch. A partial or descriptive name returns the closest matches.
result.structuredContent.results[].argsThe looked-up tool's input schema. This is the part Standard mode would have put in context for every tool up front.
3
invoke_tool run it
request · client → server
{
  "jsonrpc": "2.0",
  "id": 4,
  "method": "tools/call",
  "params": {
    "name": "invoke_tool",
    "arguments": {
      "name": "fetch",
      "arguments": { "url": "https://example.com" }
    }
  }
}
result · server → client
{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [
      { "type": "text", "text": "<!doctype html><html> ... <title>Example Domain</title> ... </html>" }
    ],
    "isError": false
  }
}
params.nameThe meta-tool being called, invoke_tool.
params.arguments.nameThe upstream tool to run, fetch.
params.arguments.argumentsThe upstream tool's own input, matching the schema get_tool returned. Here, the url to fetch.
result.content[]The upstream tool's normal result, passed straight back. The gateway does not reshape it.

What Search mode costs you

Search mode has a trade-off. Because the model cannot see the whole catalogue at once, depending on which model you are running you may need to be more explicit about which tool to use. The model has to discover the right tool through get_tool rather than having it in front of it, so you are trading context economy against the model finding the right tool unprompted. For a handful of stable tools, Standard is simpler. For dozens or hundreds, or several servers behind one endpoint, Search keeps the context down. The MCP controls field guide covers the policy side that pairs with this.

What's next for MCP

The 2026-07-28 release candidate is the biggest revision since MCP launched, and two parts of it change how a gateway handles the protocol. This work is in flight, not a shipped feature.

Stateless and sessionless

The release candidate removes the protocol-level session and the Mcp-Session-Id header (SEP-2567 for sessionless, SEP-2575 for stateless). The intent is that any request can land on any server instance, so the sticky routing and shared session stores that horizontal deployments need today are no longer a protocol concern. Anything that genuinely needs state mints its own explicit handle, for example a basket id, and passes it as an ordinary argument.

That cuts directly against how a gateway federates MCP right now. The stateful session is exactly what pins a client to its set of backends. Take the session away and the gateway has to carry routing some other way, most likely its own explicit handle rather than the protocol's session. The upside is real though: once the core is stateless, an MCP-aware gateway can treat the traffic much more like ordinary stateless HTTP, which is a cleaner story for sizing and scaling than the session-pinned model.

 Today (2025-11-25)Release candidate (2026-07-28)
SessionStateful, Mcp-Session-Id header issued at initRemoved at the protocol layer
RoutingFollow-ups pinned to the same instanceAny request can hit any instance
App stateCarried implicitly by the sessionApp mints its own explicit handle, passed as an argument
Long-running workNot part of MCPTasks extension

Tasks, the same idea A2A already has

The other change to watch is Tasks (SEP-2663). The split between MCP and A2A has always been roughly this: MCP connects a model to tools and context and is a call-and-return shape, while A2A solves long-running work and state across multiple agents. Tasks brings the long-running side into MCP itself. A tools/call can come back as a task rather than a finished result, and the client polls, updates or cancels it with task methods such as tasks/get, tasks/update and tasks/cancel. It is MCP growing the long-running capability that A2A was carrying.

MCP tools, resources, prompts model to tools call and return A2A long-running tasks state across agents agent to agent Tasks SEP-2663 tasks/get · update · cancel the long-running pattern moves into MCP

Tasks is MCP picking up the long-running, pollable work that has lived on the A2A side.

Alongside those two there is MCP Apps moving to a core extension, and changes to how server-initiated requests work. For a gateway the headline is the pair above: the protocol is going stateless, and it is growing a task model. Both reshape what an MCP front door has to do.

Sources