MastertheMesh
Solo · agentgateway · MCP 2026-07-28 · MRTR · Tasks · elicitation · Gateway API · kind
Built · CRDs only, every payload captured live

MCP 2026-07-28 on the wire, live: MRTR and Tasks through agentgateway

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 lab, end to end KIND CLUSTER · NAMESPACE mcp-2026 agentgateway controller helm chart 1.4.1 · agentgateway-system watches the Gateway CRD, deploys the v1.4.1 proxy curl (you) mcp-gw proxy agentgateway:v1.4.1 ops-mcp stdlib MCP 2026-07-28 server localhost:30080 StreamableHTTP /mcp Declared entirely through the Kubernetes API: Gateway AgentgatewayParameters AgentgatewayBackend HTTPRoute

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:

  1. 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.
  2. The caching fields have been rewritten. The server actually sent ttlMs: 60000, cacheScope: "public", and the gateway returned 0 and private. 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"}}
The routing key cannot lie Mcp-Method: tools/list body: "method": "tools/call" curl agentgateway ops-mcp 400 · -32020 Mcp-Method header/body mismatch never forwarded

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
    }
}
MRTR: pause on one pod, resume on its replacement curl mcp-gw proxy pod …-nndpj tools/call cleanup_files (id: 1) resultType: input_required inputRequests + requestState kubectl rollout restart deploy/ops-mcp terminated new pod pod …-6fcmn tools/call retry (id: 2) inputResponses + requestState echoed resultType: complete · 3 files deleted The pod that asked the question no longer exists. The retry carried everything its replacement needed.

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
        }
    }
}
One task, one approval gate tools/call → taskId tasks/get → inputRequests tasks/update · approve: true tasks/get → result working fetch · build · test input_required approve_deploy elicitation working deploy to production completed release 2026.31 deployed "stage: fetch sources" "waiting for deploy approval" "stage: deploy to production" "pipeline finished" Captured run: task-3q6_TNZN created 12:41:46Z, paused 12:41:58Z, completed 12:42:03Z. The elicitation object is the same shape MRTR delivers inline.

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:

OSS
agentgateway (OSS, Kubernetes)v1.4.1
Gateway APIv1.4.0
MCP protocol2026-07-28
Kubernetes (kind v0.31.0)v1.35.0