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.
- The four JSON-RPC phases (
initialize,notifications/initialized,tools/list,tools/call) field by field, and why theMcp-Session-Idheader makes MCP stateful. - How agentgateway multiplexes many MCP servers behind one client
session, namespaces tool names as
{server}_{tool}, and trims the list with CEL onjwt.<claim>andmcp.tool.name, either as an ingress gateway or an ambient waypoint. - How
toolMode: Searchprogressive disclosure collapses a large catalogue toget_toolandinvoke_tool, and what that trades away. - What the
2026-07-28release candidate changes: stateless, sessionless MCP and the Tasks extension for long-running work.
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.
- stdio. A server running on the same machine, JSON-RPC over stdin and stdout.
- Streamable HTTP. A remote server, over HTTPS.
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.
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.
initialize negotiate · capability handshake
{
"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" }
}
}
| protocolVersion | The MCP version the client proposes. The server replies with the version it will actually use, and may down-negotiate to a lower common one. |
| capabilities | The host-side primitives the server is allowed to rely on. |
| roots.listChanged | The client will send a notification if its set of filesystem or URI roots changes. |
| sampling | An empty object is the signal that the client lets the server request model completions back through it. No sub-fields needed. |
| elicitation | The client can surface server-initiated prompts to the user mid-session. |
| clientInfo | Human-readable client name and version, used in logs and UIs. |
{
"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."
}
}
| protocolVersion | The 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. |
| capabilities | What the server offers, and whether each primitive emits change notifications. |
| tools.listChanged | The server will notify the client when tools are added or removed at runtime. |
| resources.subscribe | The client may subscribe to updates on individual resources. |
| resources.listChanged | The server will notify when the resource list changes. |
| prompts | An empty object means prompts are supported with no optional sub-features declared. |
| serverInfo | Server name and version. |
| instructions | Optional free text the host can fold into the system prompt to steer tool use. |
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. |
tools/list discover
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }
| params.cursor | Optional. An opaque pagination token from a previous response's nextCursor. Omit it on the first page. |
{
"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. |
| name | Unique programmatic id. This exact string is what the model emits to call the tool. |
| title | Optional human display name. Distinct from name, which stays machine-stable. |
| description | The natural-language text the model reasons over to decide whether to call the tool. Effectively prompt content, so quality matters. |
| inputSchema | JSON Schema for the arguments. type is always object, properties defines each argument, required lists the mandatory ones. This is the per-tool argument surface. |
| outputSchema | Optional. JSON Schema for the structured result. If present, the server returns conforming structuredContent. |
| annotations | Optional behavioural hints: readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Advisory only, not enforced, and untrusted unless the server is. |
| nextCursor | Pagination token for the next page, or null when the list is complete. |
tools/call call
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_cluster_status",
"arguments": { "cluster": "ambient-prod-1" }
}
}
| params.name | The name of the tool to run, exactly as advertised in tools/list. |
| params.arguments | The argument values, which must satisfy that tool's inputSchema. The server validates before executing. |
{
"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[].text | For a text block, the model-readable string. |
| structuredContent | Optional machine-readable JSON conforming to outputSchema. A tool that returns this should also serialise it into a text block for backward compatibility. |
| isError | true 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. |
| message | Human-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.
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 an ambient mesh. You run agentgateway as a proxy in the cluster and point the client at it. It is the MCP endpoint the client connects to, and the policy is bound to that gateway. There is no ztunnel and no transparent redirect: the client has to use the gateway's address.
- With an ambient mesh (Istio). The same agentgateway
runs as a waypoint. There are no per-pod sidecars. ztunnel carries
Layer 4 at the node and transparently redirects the client's traffic
through the waypoint for Layer 7, so you get the same enforcement
without the client targeting a gateway at all. You attach the waypoint
to a namespace, a service or a service account, and bind the policy to
it. That is what lets a kagent
AccessPolicy(or anEnterpriseAgentgatewayPolicy) deny down to an individual MCP tool. Without a waypoint in front of the MCP server there is no Layer 7 hop to apply the rule on.
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.
| toolMode | What the client sees on tools/list | When to use it |
|---|---|---|
| Standard | Every upstream tool, filtered by RBAC. The full catalogue with every input schema. | A small, stable tool set that fits comfortably in context. |
| Search | Two meta-tools only: get_tool and invoke_tool. | Many tools, or several servers, where the full list would crowd the context window. |
| Code | One 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.
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.
tools/list what the client sees · Search mode
{
"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_tool | The 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.outputSchema | What 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_tool | The execution tool. Takes the chosen tool name and an arguments object that holds the upstream tool's own input. |
get_tool look up one tool
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "get_tool",
"arguments": { "name": "fetch" }
}
}
{
"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.name | The meta-tool being called, get_tool. |
| params.arguments.name | The tool to look up, fetch. A partial or descriptive name returns the closest matches. |
| result.structuredContent.results[].args | The looked-up tool's input schema. This is the part Standard mode would have put in context for every tool up front. |
invoke_tool run it
{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "invoke_tool",
"arguments": {
"name": "fetch",
"arguments": { "url": "https://example.com" }
}
}
}
{
"jsonrpc": "2.0",
"id": 4,
"result": {
"content": [
{ "type": "text", "text": "<!doctype html><html> ... <title>Example Domain</title> ... </html>" }
],
"isError": false
}
}
| params.name | The meta-tool being called, invoke_tool. |
| params.arguments.name | The upstream tool to run, fetch. |
| params.arguments.arguments | The 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) | |
|---|---|---|
| Session | Stateful, Mcp-Session-Id header issued at init | Removed at the protocol layer |
| Routing | Follow-ups pinned to the same instance | Any request can hit any instance |
| App state | Carried implicitly by the session | App mints its own explicit handle, passed as an argument |
| Long-running work | Not part of MCP | Tasks 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.
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
- Model Context Protocol specification,
2025-11-25(lifecycle, tools, schemas, session management) and the2026-07-28release candidate (stateless, sessionless, Tasks) at modelcontextprotocol.io and blog.modelcontextprotocol.io. SEP-2567, SEP-2575 and SEP-2663 are published there. - Solo docs: MCP connectivity, stateful MCP sessions, tool modes, tool access, and agentic mesh (waypoint and egress).
- Solo.io blog, Reduce MCP Token Usage with Agentgateway Progressive Disclosure (the 10,877 to 970 figure).
- The agentgateway project at agentgateway.dev.