The final MCP specification landed on 28 July and I walked through the wire shapes in the blog post. This lab runs them for real. You bring up a kind cluster, install the agentgateway v1.4.1 (OSS) control plane with helm, and declare the whole gateway through the Kubernetes API: a Gateway, an AgentgatewayParameters, an AgentgatewayBackend and an HTTPRoute. Then you drive the protocol with plain curl so nothing is hidden: server/discover, a tool call that pauses mid-flight with MRTR and resumes after the entire server deployment has been replaced, and a release pipeline that runs as an MCP Task with a human approval gate in the middle.
The MCP server behind the gateway is a single Python file with no SDK and no dependencies beyond the standard library. That is the point of the stateless rewrite: the protocol is now plain enough that a screenful of stdlib code speaks it, and the continuation state for an interrupted call rides in the payload, HMAC-protected, instead of living in a session.
The whole stack is four CRDs and one helm install. The controller watches the Gateway and deploys the v1.4.1 proxy; the AgentgatewayBackend tells it where the MCP server lives; kind's port mapping puts the NodePort on your localhost.
1. Bring the cluster and control plane up
Prerequisites: docker, kind, kubectl, helm and curl (python3 only for pretty-printing). Every block below is copy-paste-runnable as-is; the only step that references the lab folder is the ConfigMap holding the server source.
Create the kind cluster
kind create cluster --config - <<'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: mcp-2026
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080 # the gateway NodePort
hostPort: 30080
protocol: TCP
EOF
Install Gateway API and the agentgateway control plane
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.4.0/standard-install.yaml
helm upgrade -i agentgateway-crds oci://cr.agentgateway.dev/charts/agentgateway-crds \
--version 1.4.1 -n agentgateway-system --create-namespace
helm upgrade -i agentgateway oci://cr.agentgateway.dev/charts/agentgateway \
--version 1.4.1 -n agentgateway-system
kubectl -n agentgateway-system rollout status deploy/agentgateway --timeout=180s
Chart version 1.4.1 pins the data plane too: every proxy this control plane deploys runs agentgateway:v1.4.1, the release that carries the final 2026-07-28 protocol and the Tasks extension.
2. Declare everything through the Kubernetes API
Deploy the MCP server
First the MCP server. The single source file rides in a ConfigMap (from the lab folder), and the Deployment is a stock python:3.12-slim pod running it:
kubectl create namespace mcp-2026
kubectl -n mcp-2026 create configmap ops-mcp-src --from-file=server.py=src/server.py
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Secret
metadata:
name: ops-mcp-state-key
namespace: mcp-2026
stringData:
secret: change-me-lab-only-0b9f4c # HMAC key for MRTR requestState
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ops-mcp
namespace: mcp-2026
spec:
replicas: 1
selector:
matchLabels:
app: ops-mcp
template:
metadata:
labels:
app: ops-mcp
spec:
containers:
- name: server
image: python:3.12-slim
command: ["python", "/app/server.py"]
env:
- name: MCP_STATE_SECRET
valueFrom:
secretKeyRef:
name: ops-mcp-state-key
key: secret
ports:
- containerPort: 8000
readinessProbe:
httpGet:
path: /healthz
port: 8000
volumeMounts:
- name: src
mountPath: /app
volumes:
- name: src
configMap:
name: ops-mcp-src
---
apiVersion: v1
kind: Service
metadata:
name: ops-mcp
namespace: mcp-2026
spec:
selector:
app: ops-mcp
ports:
- port: 8000
targetPort: 8000
EOF
Create the Gateway
Now the gateway itself. The Gateway asks the controller for a proxy; the AgentgatewayParameters shapes the generated Service so kind's mapped host port reaches the listener:
kubectl apply -f - <<'EOF'
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayParameters
metadata:
name: mcp-gw-params
namespace: mcp-2026
spec:
service:
spec:
type: NodePort
ports:
- $patch: replace
- name: mcp
port: 3000
targetPort: 3000
nodePort: 30080
protocol: TCP
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: mcp-gw
namespace: mcp-2026
spec:
gatewayClassName: agentgateway
infrastructure:
parametersRef:
group: agentgateway.dev
kind: AgentgatewayParameters
name: mcp-gw-params
listeners:
- name: mcp
protocol: HTTP
port: 3000
allowedRoutes:
namespaces:
from: Same
EOF
Wire the MCP backend to the gateway
And the MCP wiring: an AgentgatewayBackend naming the ops-mcp Service as a StreamableHTTP target, and an HTTPRoute binding it to the gateway:
kubectl apply -f - <<'EOF'
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
name: ops-mcp
namespace: mcp-2026
spec:
mcp:
targets:
- name: ops
static:
backendRef:
name: ops-mcp
port: 8000
path: /mcp
protocol: StreamableHTTP
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: mcp
namespace: mcp-2026
spec:
parentRefs:
- name: mcp-gw
rules:
- backendRefs:
- group: agentgateway.dev
kind: AgentgatewayBackend
name: ops-mcp
EOF
Check everything is ready
Wait for both rollouts, then check the CRD statuses:
kubectl -n mcp-2026 rollout status deploy/ops-mcp --timeout=180s
kubectl -n mcp-2026 rollout status deploy/mcp-gw --timeout=180s
kubectl -n mcp-2026 get gateway,agentgatewaybackend
NAME CLASS ADDRESS PROGRAMMED AGE
gateway.gateway.networking.k8s.io/mcp-gw agentgateway 10.96.204.30 True 4m4s
NAME ACCEPTED AGE
agentgatewaybackend.agentgateway.dev/ops-mcp True 4m4s
Or run the fast path, which is exactly these commands in order: ./scripts/up.sh.
3. No handshake: the first request on the wire
Send the first request: server/discover
There is no initialize. Here is a complete request, every header and every byte of the body. The _meta block is the part to study: with the handshake gone, the protocol version and the client's capabilities travel on every request, and this client declares both elicitation and the tasks extension.
curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: server/discover' \
--data @- <<EOF
{
"jsonrpc": "2.0",
"id": 1,
"method": "server/discover",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}
}
EOF
The raw response comes back in SSE framing: an event: message line, then a single data: line carrying the whole JSON-RPC result. On the wire the JSON is one line; it is wrapped here so you can read it:
event: message
data: {
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"supportedVersions": ["2026-07-28"],
"capabilities": {
"extensions": {"io.modelcontextprotocol/tasks": {}},
"tools": {}
},
"ttlMs": 0,
"cacheScope": "private"
}
}
Two things to notice:
- The tasks extension is forwarded through. The server advertises it, the gateway passes it on, so the client knows before any call that task handles may come back.
- The caching fields have been rewritten. The server actually sent
ttlMs: 60000, cacheScope: "public", and the gateway returned0andprivate. That is deliberate policy, not a bug: agentgateway applies authorisation and routing decisions per request, so it refuses to let an intermediary cache a result the next request might not have been allowed to see.
List the tools
From here on, every command appends | sed -n 's/^data: //p' | python3 -m json.tool to strip the SSE framing and pretty-print; the request part is always the complete curl. tools/list shows the three tools this lab uses:
curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
--data @- <<EOF | sed -n 's/^data: //p' | python3 -m json.tool
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}
}
EOF
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "private",
"tools": [
{
"name": "list_stale_files",
"description": "List files under /data/tmp that have not been touched in 90 days.",
"inputSchema": {
"type": "object"
}
},
{
"name": "cleanup_files",
"description": "Delete the stale files under /data/tmp. Asks a human for confirmation first (MRTR elicitation).",
"inputSchema": {
"type": "object"
}
},
{
"name": "run_pipeline",
"description": "Run the release pipeline: build, test, then pause for a human deploy approval. Long-running; returns an MCP Task.",
"inputSchema": {
"type": "object"
}
}
]
}
}
Watch the gateway's access log
The gateway's access log names the MCP method, target and resource type on every line without parsing bodies twice, which is the SEP-2243 headers doing their job:
kubectl -n mcp-2026 logs deploy/mcp-gw | grep mcp.method
info request gateway=mcp-2026/mcp-gw listener=mcp route=mcp-2026/mcp ... http.status=200 protocol=mcp mcp.method.name=server/discover duration=1ms
info request gateway=mcp-2026/mcp-gw listener=mcp route=mcp-2026/mcp ... http.status=200 protocol=mcp mcp.method.name=tasks/get mcp.target=ops mcp.resource.type=task duration=0ms
4. The headers cannot lie
Send a request whose header lies about the body
SEP-2243 says the Mcp-Method header must mirror the JSON-RPC body, and mismatches must be rejected. Try to sneak a tools/call past a header that claims it is a harmless tools/list:
curl -si http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/list' \
--data @- <<EOF
{
"jsonrpc": "2.0",
"id": 40,
"method": "tools/call",
"params": {
"name": "list_stale_files",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {}
}
}
}
EOF
HTTP/1.1 400 Bad Request
content-type: application/json
{"jsonrpc":"2.0","id":40,"error":{"code":-32020,"message":"Mcp-Method header/body mismatch"}}
Rejected at the gateway, before any backend sees it. -32020 is HeaderMismatchError from the spec's newly reserved MCP error range. This is what makes header-based routing and policy trustworthy: anything in the path can key decisions off Mcp-Method and Mcp-Name knowing the body agrees.
5. MRTR: the question survives the death of the pod that asked it
Pause the call: ask to delete the files
cleanup_files wants a human to confirm before deleting anything. Under the old protocol the server would push an elicitation up a held-open SSE stream. Under 2026-07-28 it returns early instead. Capture the requestState while you are at it, because the retry has to echo it back:
STATE=$(curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: cleanup_files' \
--data @- <<EOF | sed -n 's/^data: //p' | tee /dev/stderr | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['requestState'])"
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "cleanup_files",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}
}
EOF
)
{
"jsonrpc": "2.0",
"id": 10,
"result": {
"resultType": "input_required",
"inputRequests": {
"confirm_cleanup": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Delete 3 stale files under /data/tmp?",
"requestedSchema": {
"type": "object",
"properties": {"confirm": {"type": "boolean"}},
"required": ["confirm"]
}
}
}
},
"requestState": "eyJleHAiOjE3ODU3NjExNjQsImZpbGVzIjpb...d7e19c877a89443f3ee5e0a42c041c56"
}
}
The call is over. No connection is held, no session exists anywhere. inputRequests carries a full elicitation request, and requestState is the server's continuation state: which files it intended to delete and an expiry, HMAC-signed so the server can trust it when it comes back. The client must echo it exactly and never look inside.
Replace every server pod mid-question
Note which pod asked the question, then replace the entire server deployment before answering:
kubectl -n mcp-2026 logs -l app=ops-mcp --prefix | grep input_required
# [pod/ops-mcp-7ff4d76c58-nndpj/server] cleanup_files: pausing with input_required (asked on pod ops-mcp-7ff4d76c58-nndpj)
kubectl -n mcp-2026 rollout restart deploy/ops-mcp
kubectl -n mcp-2026 rollout status deploy/ops-mcp
Answer the question and retry the call
Every pod that existed when the question was asked is on its way out. Under 2025-11-25 this conversation is dead: the session and the held stream died with the pod. Retry the original call with the answer attached and the state echoed back:
curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: cleanup_files' \
--data @- <<EOF | sed -n 's/^data: //p' | python3 -m json.tool
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "cleanup_files",
"arguments": {},
"inputResponses": {
"confirm_cleanup": {"action": "accept", "content": {"confirm": true}}
},
"requestState": "$STATE",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}
}
EOF
{
"jsonrpc": "2.0",
"id": 11,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "deleted 3 files: /data/tmp/build-2019.log, /data/tmp/core.1842, /data/tmp/report-old.csv (resumed on pod ops-mcp-598545d5b-6fcmn)"
}
],
"isError": false
}
}
Paused on …-nndpj, resumed on …-6fcmn. Nothing coordinated this: no shared store, no sticky routing, no session. The continuation state rode through the client inside requestState.
Try to tamper with requestState
The spec is equally clear that requestState passes through the client and is therefore attacker-controlled. This server binds the tool name and an expiry inside the HMAC, so a tampered blob dies at verification. Flip one character and replay it:
curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: cleanup_files' \
--data @- <<EOF | sed -n 's/^data: //p'
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "cleanup_files",
"arguments": {},
"inputResponses": {
"confirm_cleanup": {"action": "accept", "content": {"confirm": true}}
},
"requestState": "${STATE%?}x",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {"elicitation": {}}
}
}
}
EOF
{"jsonrpc": "2.0", "id": 12, "error": {"code": -32602, "message": "invalid or expired requestState"}}
6. Tasks: a pipeline with a human in the middle
Start the pipeline and capture the task handle
run_pipeline takes around twenty seconds and needs a deploy approval partway through, so the server answers with a task handle instead of making the client wait. The task is durably created before the response is sent. Capture the ID for the polls that follow:
TID=$(curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: run_pipeline' \
--data @- <<EOF | sed -n 's/^data: //p' | tee /dev/stderr | python3 -c "import json,sys; print(json.load(sys.stdin)['result']['taskId'])"
{
"jsonrpc": "2.0",
"id": 20,
"method": "tools/call",
"params": {
"name": "run_pipeline",
"arguments": {},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}
}
EOF
)
{
"jsonrpc": "2.0",
"id": 20,
"result": {
"resultType": "task",
"taskId": "task-3q6_TNZN",
"status": "working",
"createdAt": "2026-08-03T12:41:46Z",
"lastUpdatedAt": "2026-08-03T12:41:46Z",
"ttlMs": 600000,
"pollIntervalMs": 2000
}
}
Poll the task
One gateway rule to know before polling: on tasks/* requests the Mcp-Name header carries the task ID, the same way it carries the tool name on tools/call. That is what lets the gateway route a poll back to the backend that owns the task without opening the body. Omit it and agentgateway rejects the request outright with the same -32020 family you saw in section 4:
{"jsonrpc":"2.0","id":41,"error":{"code":-32020,"message":"invalid MCP routing header: Mcp-Name"}}
Poll it properly:
curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tasks/get' \
-H "Mcp-Name: $TID" \
--data @- <<EOF | sed -n 's/^data: //p' | python3 -m json.tool
{
"jsonrpc": "2.0",
"id": 21,
"method": "tasks/get",
"params": {
"taskId": "$TID",
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}
}
EOF
{
"jsonrpc": "2.0",
"id": 21,
"result": {
"resultType": "complete",
"taskId": "task-3q6_TNZN",
"status": "working",
"statusMessage": "stage: fetch sources",
"createdAt": "2026-08-03T12:41:46Z",
"lastUpdatedAt": "2026-08-03T12:41:46Z",
"ttlMs": 600000,
"pollIntervalMs": 2000
}
}
After the build and test stages finish, the same poll comes back different. The task has paused, and the payload it carries should look familiar, because it is the same elicitation object MRTR delivered inline in section 5, arriving through the polling channel instead:
{
"jsonrpc": "2.0",
"id": 22,
"result": {
"resultType": "complete",
"taskId": "task-3q6_TNZN",
"status": "input_required",
"statusMessage": "waiting for deploy approval",
"createdAt": "2026-08-03T12:41:46Z",
"lastUpdatedAt": "2026-08-03T12:41:58Z",
"ttlMs": 600000,
"pollIntervalMs": 2000,
"inputRequests": {
"approve_deploy": {
"method": "elicitation/create",
"params": {
"mode": "form",
"message": "Build and tests passed. Deploy release 2026.31 to production?",
"requestedSchema": {
"type": "object",
"properties": {"approve": {"type": "boolean"}},
"required": ["approve"]
}
}
}
}
}
}
Approve the deploy with tasks/update
Approve it with tasks/update, keyed identically to the request:
curl -s http://localhost:30080/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tasks/update' \
-H "Mcp-Name: $TID" \
--data @- <<EOF | sed -n 's/^data: //p'
{
"jsonrpc": "2.0",
"id": 23,
"method": "tasks/update",
"params": {
"taskId": "$TID",
"inputResponses": {
"approve_deploy": {"action": "accept", "content": {"approve": true}}
},
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "curl", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {
"elicitation": {},
"extensions": {"io.modelcontextprotocol/tasks": {}}
}
}
}
}
EOF
{"jsonrpc": "2.0", "id": 23, "result": {"resultType": "complete"}}
Poll to completion
The pipeline resumes, deploys, and the next tasks/get poll is terminal, with the result the original tools/call would have returned synchronously:
{
"jsonrpc": "2.0",
"id": 24,
"result": {
"resultType": "complete",
"taskId": "task-3q6_TNZN",
"status": "completed",
"statusMessage": "pipeline finished",
"createdAt": "2026-08-03T12:41:46Z",
"lastUpdatedAt": "2026-08-03T12:42:03Z",
"ttlMs": 600000,
"result": {
"content": [{"type": "text", "text": "release 2026.31 deployed: 4 stages ok (pod ops-mcp-598545d5b-6fcmn)"}],
"isError": false
}
}
}
The same interaction primitive at a different durability. A paused overnight job and a mid-call confirmation present the same inputRequests map to the client; only the delivery channel differs.
Cancel a second pipeline
Cancellation is cooperative. Start a second pipeline, cancel it with tasks/cancel (again with the task ID in Mcp-Name), and the worker acknowledges at its next checkpoint rather than dying mid-stage. The follow-up poll reports:
{
"jsonrpc": "2.0",
"id": 33,
"result": {
"resultType": "complete",
"taskId": "task-vPgJQ5GK",
"status": "cancelled",
"statusMessage": "cancelled before build images",
"createdAt": "2026-08-03T12:42:05Z",
"lastUpdatedAt": "2026-08-03T12:42:09Z",
"ttlMs": 600000
}
}
Note what does not exist: tasks/list. Without sessions there is no safe way to scope whose tasks, so there is no enumeration at all. You hold task-3q6_TNZN or you don't. When agentgateway multiplexes several MCP backends behind one route it namespaces the IDs per backend, so the handle also encodes who owns the work; you can see the gateway already tracking this in the access log's mcp.target=ops mcp.resource.type=task fields.
7. Tear it down
kind delete cluster --name mcp-2026
For the full background on every shape you just saw, the blog post is MCP went stateless: the 2026-07-28 spec on the wire. To keep going at the gateway layer: enterprise MCP controls, elicitation for upstream OAuth and gateway-enforced human approval for tool calls.
Versions
Built and verified on:
v1.4.1v1.4.02026-07-28v1.35.0