Bump an MCP server to the new SDK and the first run dies on an import. Fix that and it dies on the constructor. Fix that and every tool that asks the user a question breaks on the new protocol while still working for old clients. That is the MCP 2026-07-28 migration in three errors, and all three are cheap to fix once you know which line to change.
This guide migrates a real Streamable HTTP server in Python (FastMCP to MCPServer) and in TypeScript (the official codemod plus createMcpHandler), then proves the stateless wire with curl: server/discover, the new required headers, the multi round-trip (MRTR) replacement for elicitation, and the error codes you get when a request is wrong. If you only consume servers, the Claude Code MCP setup guide covers the client side, and MCP vs RAG vs agent skills explains where MCP fits at all. Everything below was run on Ubuntu 26.04 in September 2026 with the MCP Python SDK 2.2.0, the TypeScript SDK 2.1.0, Claude Code 2.1.283 and MCP Inspector 2.8.0.
What the MCP 2026-07-28 migration breaks
The revision makes MCP stateless. There is no initialize handshake and no Mcp-Session-Id, and the server can no longer send requests to the client. Every request carries its own protocol version and client capabilities in _meta, so any worker behind a load balancer can answer any request. The official specification changelog lists nine major changes; these are the ones that break running code:
| Removed in 2026-07-28 | Replaced by |
|---|---|
initialize / notifications/initialized handshake | Per-request _meta keys plus a mandatory server/discover RPC |
Mcp-Session-Id header and protocol sessions | Nothing. Cross-call state goes in explicit handles you pass as tool arguments |
Server-initiated elicitation/create, sampling/createMessage, roots/list | MRTR: the server returns resultType: "input_required" and the client retries with answers |
HTTP GET stream, resources/subscribe | One long-lived POST: subscriptions/listen |
ping, logging/setLevel | Log level per request via io.modelcontextprotocol/logLevel in _meta |
SSE resumability (Last-Event-ID) | Re-issue the request with a new ID |
Two new obligations come with it. Every result carries a resultType field, and every Streamable HTTP POST must send MCP-Protocol-Version, Mcp-Method and, for tools/call, resources/read and prompts/get, an Mcp-Name header that matches the body.
Check which clients already speak the new protocol
You do not have to migrate everything on one day. The Python SDK v2 serves 2025-era and 2026-era clients from the same endpoint with no configuration, the TypeScript SDK v2 does the same once you switch to createMcpHandler, and a v2 client falls back to the old handshake when a server has not been upgraded. We ran every pairing we could build on the lab VM and captured what each side negotiated:
| Client | Server | Negotiated |
|---|---|---|
Python SDK v1 ClientSession | Python SDK v2 MCPServer | 2025-11-25, session ID issued, elicitation works |
Python SDK v2 Client, mode="auto" | Python SDK v2 MCPServer | 2026-07-28 |
Python SDK v2 Client, mode="auto" | Python SDK v1 FastMCP | 2025-11-25 (server/discover gets HTTP 400, client falls back) |
Python SDK v2 Client, mode="auto" | TypeScript v2 createMcpHandler | 2026-07-28 |
| Claude Code | Python SDK v2 MCPServer | 2026-07-28 (server/discover, then subscriptions/listen) |
| Claude Code | Python SDK v1 FastMCP | 2025-11-25 after a failed server/discover probe, session ID issued |
| MCP Inspector, default settings | Python SDK v2 MCPServer | 2025-11-25, because Inspector defaults to Legacy |
2026-07-28 tools/call | TypeScript server after the codemod only | Rejected: Unsupported protocol version: 2026-07-28 |
The Claude Code result comes from a packet capture on the server, not from a changelog. It opens with a server/discover probe, mcp-protocol-version: 2026-07-28 in the headers and "name":"claude-code" in clientInfo, so a migrated server gets modern traffic the moment you point the Claude Code mcp add command at it. The last row is the one that catches people out, and the TypeScript section explains it.
Upgrade a Python MCP server to SDK v2
The test server is a small FastMCP app with three DevOps tools: disk_usage, check_port, and restart_service, which asks the user to confirm before it does anything. It runs Streamable HTTP on port 8000 and was built against mcp==1.30.0, the last v1 release. Commit your work first, then move the dependency to the v2 line inside the project:
uv add "mcp[cli]>=2,<3"
v2 depends on mcp-types, httpx2 and opentelemetry-api, which the v1 line did not. Start the unchanged server to see the first break:
uv run server.py
Error: “No module named ‘mcp.server.fastmcp'”
The traceback ends with a message that tells you exactly what happened:
from mcp.server.fastmcp import Context, FastMCP
File "/home/jmutai/ops-mcp/.venv/lib/python3.14/site-packages/mcp/server/fastmcp.py", line 16, in <module>
raise ModuleNotFoundError(_MESSAGE, name=__name__)
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x, where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import MCPServer) and other APIs changed; see the migration guide at https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver or pin 'mcp<2' to keep running v1 code.
FastMCP is now MCPServer and lives under mcp.server.mcpserver. The decorators (@mcp.tool(), @mcp.resource(), @mcp.prompt()) did not change. One sed handles both the import and the class name:
sed -i 's/from mcp.server.fastmcp import Context, FastMCP/from mcp.server.mcpserver import Context, MCPServer/; s/^mcp = FastMCP(/mcp = MCPServer(/' server.py
Error: “MCPServer.__init__() got an unexpected keyword argument ‘host'”
The second run gets past the import and fails on the constructor:
File "/home/jmutai/ops-mcp/server.py", line 12, in <module>
mcp = MCPServer("ops-tools", host="0.0.0.0", port=8000)
TypeError: MCPServer.__init__() got an unexpected keyword argument 'host'
Every transport option moved off the constructor and onto run() or streamable_http_app(): host, port, streamable_http_path, json_response, stateless_http and transport_security. The v1 line mcp = FastMCP("ops-tools", host="0.0.0.0", port=8000) becomes a bare constructor plus mcp.run(transport="streamable-http", host="0.0.0.0", port=8000). Pass version= while you are there, because v2 reports an empty server version instead of the SDK’s own. Also watch positional arguments: v2 inserted title and description before instructions, so FastMCP("Demo", "You answer questions...") silently turns your instructions into a title.
This sed makes both edits on the test server:
sed -i 's/^mcp = MCPServer("ops-tools", host="0.0.0.0", port=8000)/mcp = MCPServer("ops-tools", version="2.0.0")/; s/mcp.run(transport="streamable-http")/mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)/' server.py
With those changes the server starts and serves both protocol eras on /mcp:
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
The pinned v1 client still connects, gets a session ID, and calls tools exactly as before. That is the dual-era promise working. It does not mean the migration is finished, because one tool is still written for a protocol that no longer exists.
Replace ctx.elicit() with a Resolve parameter
This is the v1 version of the confirmation tool. It pushes an elicitation/create request to the client in the middle of the call and waits for the answer:
@mcp.tool()
async def restart_service(name: str, ctx: Context) -> str:
"""Ask the user to confirm before pretending to restart a systemd unit."""
result = await ctx.elicit(
message=f"Restart {name}? This drops active connections.",
schema=Confirm,
)
if result.action == "accept" and result.data.confirm:
return f"restart of {name} queued"
return f"restart of {name} cancelled"
Error: “Cannot send ‘elicitation/create’: this transport context has no back-channel for server-initiated requests”
Legacy clients still get the prompt. A 2026-07-28 client calling the same tool gets a top-level JSON-RPC error instead of a result, whether or not it declared the elicitation capability:
{"jsonrpc":"2.0","id":8,"error":{"code":-32600,"message":"Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests."}}
The server cannot call the client any more, so the tool has to return the question instead. The v2 SDK wraps that in a resolver: a function that returns an Elicit marker, attached to a tool parameter with Annotated[..., Resolve(fn)]. On a 2025 session the SDK sends a live elicitation request. On a 2026-07-28 request it returns input_required and runs your tool body only after the client retries with the answer. One tool body serves both eras, and the resolved parameter stays out of the tool schema: tools/list shows restart_service taking only name.
Open the server file:
vim server.py
This is the complete migrated file, exactly as it ran in the lab:
import os
import shutil
import socket
from typing import Annotated
from mcp.server.mcpserver import Elicit, MCPServer, RequestStateSecurity, Resolve
from pydantic import BaseModel
class Confirm(BaseModel):
confirm: bool
mcp = MCPServer(
"ops-tools",
version="2.0.0",
request_state_security=RequestStateSecurity(keys=[os.environ["MCP_STATE_KEY"]]),
)
@mcp.tool()
def disk_usage(path: str = "/") -> dict:
"""Report total, used and free space for a mount point, in GiB."""
usage = shutil.disk_usage(path)
gib = 1024**3
return {
"path": path,
"total_gib": round(usage.total / gib, 1),
"used_gib": round(usage.used / gib, 1),
"free_gib": round(usage.free / gib, 1),
}
@mcp.tool()
def check_port(host: str, port: int, timeout: float = 2.0) -> str:
"""Check whether a TCP port is accepting connections."""
try:
with socket.create_connection((host, port), timeout=timeout):
return f"{host}:{port} is open"
except OSError as exc:
return f"{host}:{port} is closed ({exc.strerror or exc})"
async def confirm_restart(name: str) -> Elicit[Confirm]:
return Elicit(f"Restart {name}? This drops active connections.", Confirm)
@mcp.tool()
async def restart_service(name: str, answer: Annotated[Confirm, Resolve(confirm_restart)]) -> str:
"""Ask the user to confirm before pretending to restart a systemd unit."""
if answer.confirm:
return f"restart of {name} queued"
return f"restart of {name} cancelled"
if __name__ == "__main__":
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
Binding 0.0.0.0 on run() switches off the SDK’s localhost Host-header check, which is why Claude Code on another machine could connect. That also leaves the endpoint with no Host or Origin check and no authentication, and check_port becomes a port scanner for anyone who can reach port 8000, so treat this binding as lab only or firewall it. Behind a reverse proxy, bind to localhost and pass TransportSecuritySettings(allowed_hosts=...) instead, and put auth in front. The request_state_security line is there because of the next error.
Error: “Invalid or expired requestState” after a restart or on another worker
The first MRTR round returns an opaque, signed requestState that the client must echo back on the retry. Without RequestStateSecurity, the SDK seals it with a random per-process key. We started a round, restarted the server process, then sent the retry:
{"jsonrpc":"2.0","id":21,"error":{"code":-32602,"message":"Invalid or expired requestState","data":{"reason":"invalid_request_state"}}}
A restart is the easy way to see it. In production the same thing happens whenever the retry lands on a different replica, which is exactly the scale-out case statelessness was meant to fix. Give every instance the same key, and keep the server name identical because it is the audience claim inside the token:
export MCP_STATE_KEY=$(openssl rand -hex 32)
uv run server.py
In Kubernetes that value belongs in a Secret mounted into every pod. With the shared key, the same restart test completed the round trip and returned restart of nginx queued. The token is signed, so a tampered requestState is rejected with the same -32602.
The v2 Python Client retries MRTR rounds for you. With an elicitation_callback that approves the prompt, the same tool works in both eras, and the old v1 client works too:
mode=auto negotiated=2026-07-28
server asked: Restart nginx? This drops active connections.
result: {'result': 'restart of nginx queued'}
mode=legacy negotiated=2025-11-25
server asked: Restart nginx? This drops active connections.
result: {'result': 'restart of nginx queued'}
v1 client negotiated: 2025-11-25
server asked: Restart nginx? This drops active connections.
result: restart of nginx queued
Registering elicitation_callback= is how a v2 client declares the capability. Leave it out and the server refuses before your tool runs, with error -32021: Client did not declare the form elicitation capability required by resolver '__main__:confirm_restart'.
Test the stateless MCP protocol with curl
SDK clients hide the wire, and the wire is where a hand-rolled client or proxy breaks. Set the endpoint once:
export MCP_URL=http://localhost:8000/mcp
Every server must implement server/discover. No session, no handshake, one POST:
curl -s "$MCP_URL" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Method: server/discover' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
The server lists the versions it speaks and its capabilities, and moves serverInfo into the result’s _meta:
{"jsonrpc":"2.0","id":1,"result":{"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"resultType":"complete","supportedVersions":["2026-07-28"],"ttlMs":0,"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"ops-tools","version":"2.0.0"}}}}
Drop the MCP-Protocol-Version header and the Python SDK treats the request as 2025-era traffic, answering Bad Request: Missing session ID. The TypeScript SDK is stricter and returns -32020 with the required MCP-Protocol-Version header is absent. Either way, the header is not optional.
A tool call is the same shape, plus Mcp-Name:
curl -s "$MCP_URL" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: disk_usage' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"disk_usage","arguments":{"path":"/"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{},"io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8"}}}}'
The result comes back as plain application/json, with the new resultType field:
{"jsonrpc":"2.0","id":2,"result":{"content":[{"text":"{\n \"path\": \"/\",\n \"total_gib\": 18.2,\n \"used_gib\": 3.9,\n \"free_gib\": 14.3\n}","type":"text"}],"isError":false,"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"ops-tools","version":"2.0.0"}}}}
Calling restart_service the same way, with {"elicitation":{"form":{}}} in clientCapabilities, shows the first MRTR round. The question sits under inputRequests, keyed by the resolver’s name:
{"jsonrpc":"2.0","id":8,"result":{"inputRequests":{"__main__:confirm_restart":{"method":"elicitation/create","params":{"message":"Restart nginx? This drops active connections.","mode":"form","requestedSchema":{"properties":{"confirm":{"title":"Confirm","type":"boolean"}},"required":["confirm"],"type":"object"}}}},"requestState":"v1.gEkOIhMNvs3Ru5g6-AangyzjKaprxfJ2VA87mefZQ-fWV17dICJ5-i5V14QgvHqIy3B3ukYYpKFanpalWmgXV9ZOoSh4DqXNZX1xM7qax7-PeCs4oZQ3CsnRIWTXLVAJxpw8tngecMivgBNMvdFgKIQ65t9VQlZRd4TGlTX6Y5xItQEBXqBI9uedw00elwRpgMkKL53TUgQtiKkaVL5i_COsT9J5KR5JVp9iD9vNY3L94gJI7VZtQsD8yu5qr4ArstUxkBIPGftqu7j3AO9T2389Tp7m-xVbFZrZpblM8HJsV0y7xEIEHVU0KGfasrVNnDVHDu4vVWUp1r7lykOXZE2pFg6kO75ShBOyyi7pO3CF38w","resultType":"input_required","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"ops-tools","version":"2.0.0"}}}}
The retry is the original request with a new JSON-RPC id, plus "inputResponses":{"__main__:confirm_restart":{"action":"accept","content":{"confirm":true}}} and the requestState string copied byte for byte into params. The second round returned resultType: "complete" with restart of nginx queued. These are the errors a malformed request produced against the same server:
| What we sent | HTTP | JSON-RPC error |
|---|---|---|
tools/call without Mcp-Method | 400 | -32020 mcp-method header does not match the request body's method |
Mcp-Name: check_port, body calls disk_usage | 400 | -32020 mcp-name header does not match the request body's 'name' parameter |
Protocol version 2027-01-01 | 400 | -32022 Unsupported protocol version, data.supported: ["2026-07-28"] |
restart_service without the elicitation capability | 400 | -32021 Client did not declare the form elicitation capability... |
ping | 404 | -32601 Method not found |
Tampered or foreign requestState | 400 | -32602 Invalid or expired requestState |
If you run MCP behind an API gateway, route on Mcp-Method and Mcp-Name instead of parsing JSON bodies. That is what the headers exist for, and the server rejects any request where they disagree with the body.
Migrate a TypeScript MCP server with the codemod
The TypeScript SDK split @modelcontextprotocol/sdk into @modelcontextprotocol/server, /client, /node and /core, and v2 needs Node.js 20 or newer. Ubuntu 26.04 ships Node 22 in its own repository, so sudo apt install nodejs npm is enough; the Node.js install guide covers newer lines. Our v1 server was an Express app with one server.tool() call and a stateless StreamableHTTPServerTransport. Commit it, then run the codemod from the package root, not from src/, because it also edits package.json:
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
It rewrote three things and warned about a fourth it would not touch:
Changes: 3 across 1 file(s)
Info (1):
/home/jmutai/ops-mcp-ts/src/server.ts:10 - [INFO] Raw object literal wrapped with z.object().
package.json updated:
package.json
Removed: @modelcontextprotocol/sdk
Added: @modelcontextprotocol/node, @modelcontextprotocol/server
Warning: zod range '^3.25.76' cannot satisfy v2's floor: zod >=4.2.0 is required. An older range installs cleanly and then, depending on the zod entry point your code imports, fails type-checking (zod/v4 subpath) or only fails at runtime (main-entry imports: the server starts normally and the first tools/list reports the failure).
In the source, server.tool(name, description, shape, cb) became server.registerTool(name, { description, inputSchema: z.object(...) }, cb), and StreamableHTTPServerTransport became NodeStreamableHTTPServerTransport from @modelcontextprotocol/node. The codemod does not reformat, so run Prettier afterwards. Then install and type-check:
npm install
npx tsc --noEmit
Error: “Schema appears to be from zod 3, which the SDK cannot convert to JSON Schema”
The warning was accurate. tsc rejects the zod 3 object with error TS2769: No overload matches this call, but a runner that skips type-checking, such as tsx, starts the server fine. It answers initialize, then fails the first tools/list:
data: {"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"Schema appears to be from zod 3, which the SDK cannot convert to JSON Schema. Upgrade to zod >=4.2.0, or wrap your JSON Schema with fromJsonSchema()."}}
A health check that only tests the port passes, and every client sees zero tools. Upgrade zod:
npm install zod@^4.2.0
Serve 2026-07-28 with createMcpHandler
Now the server compiles, lists tools and serves legacy clients. It still does not speak the new protocol. The TypeScript SDK never puts a 2026-07-28 byte on the wire unless you opt in, and the codemod deliberately leaves that architectural change to you. A 2026-07-28 request against the codemodded server:
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"},"id":null}
The fix is to replace the per-request transport with createMcpHandler, which takes a factory, answers 2026-07-28 requests natively and serves 2025 clients statelessly from the same factory. On Node it is wrapped in toNodeHandler(). Open the entry point:
vim src/server.ts
The finished file, verbatim from the lab. The migration guides only show this in fragments, so here it is whole:
import express from "express";
import { statfsSync } from "node:fs";
import { toNodeHandler } from "@modelcontextprotocol/node";
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
function buildServer(): McpServer {
const server = new McpServer({ name: "ops-tools-ts", version: "2.0.0" });
server.registerTool(
"disk_usage",
{
description: "Report total and free space for a mount point, in GiB",
inputSchema: z.object({ path: z.string().default("/") }),
},
async ({ path }) => {
const s = statfsSync(path);
const gib = 1024 ** 3;
const total = ((s.blocks * s.bsize) / gib).toFixed(1);
const free = ((s.bavail * s.bsize) / gib).toFixed(1);
return { content: [{ type: "text", text: `${path}: ${free} GiB free of ${total} GiB` }] };
},
);
return server;
}
const handler = createMcpHandler(buildServer);
const app = express();
app.all("/mcp", toNodeHandler(handler));
app.listen(3000, "0.0.0.0", () => console.log("ops-tools-ts listening on :3000/mcp"));
There is no express.json() in front of the handler; toNodeHandler() takes the raw Node request. The tools/call curl from earlier, pointed at port 3000 with empty arguments, now returns a 2026-07-28 result:
{"result":{"content":[{"type":"text","text":"/: 14.2 GiB free of 18.2 GiB"}],"resultType":"complete","_meta":{"io.modelcontextprotocol/serverInfo":{"name":"ops-tools-ts","version":"2.0.0"}}},"jsonrpc":"2.0","id":2}
The legacy handshake still answers with "protocolVersion":"2025-11-25", and the Python v2 client negotiated 2026-07-28 against this TypeScript server without any glue. If you need sessionful 2025 behavior kept for old clients, pass { legacy: 'reject' } to createMcpHandler and route legacy requests to your existing transport. The TypeScript migration docs show that split.
Check the migrated server in MCP Inspector
MCP Inspector is the fastest way to see which era a server actually negotiates, but out of the box it will not show you. Start it with npx @modelcontextprotocol/inspector, add the server, and it connects with the 2025-11-25 handshake. Its own help text says it “Defaults to Legacy” because debugging tools should not auto-probe. Open the server’s Settings before connecting and change Protocol Era from Legacy to Modern:

After connecting, the server card reads MCP 2026-07-28, and the message log (sorted oldest first) opens with SERVER/DISCOVER and a pending SUBSCRIPTIONS/LISTEN in place of INITIALIZE:

Run restart_service from the Tools tab to test the Resolve port. Inspector labels the call MRTR, holds it at one round awaiting input, and renders the form the server returned in inputRequests:

Tick Confirm and submit. The retry completes the call in two rounds, and the structured output matches what the curl retry returned:

Run the same check with Protocol Era on Legacy before you ship. Both eras should reach the same result, because that is the whole contract of a dual-era server.
MCP SDK v1 to v2 migration checklist
Every row below broke or changed something on the lab servers. The Python migration guide has the long tail (snake_case fields, McpError to MCPError, httpx to httpx2) if your server touches protocol types directly.
| v1 | v2 | Symptom if missed |
|---|---|---|
from mcp.server.fastmcp import FastMCP | from mcp.server.mcpserver import MCPServer | ModuleNotFoundError at import |
FastMCP(name, host=, port=) | MCPServer(name, version=) + run(host=, port=) | TypeError: unexpected keyword argument |
await ctx.elicit(...) | Annotated[Model, Resolve(fn)] returning Elicit(...) | -32600 no back-channel on 2026-07-28 clients |
| Per-process MRTR key (default) | RequestStateSecurity(keys=[shared_key]) | -32602 Invalid or expired requestState across replicas |
@modelcontextprotocol/sdk | @modelcontextprotocol/server + /node (codemod) | Import errors |
server.tool(name, desc, shape, cb) | server.registerTool(name, { description, inputSchema }, cb) | TypeError: server.tool is not a function (the variadic form was removed) |
| zod 3 | zod 4.2 or newer | -32603 on tools/list, zero tools |
StreamableHTTPServerTransport | createMcpHandler(factory) + toNodeHandler() | 2026-07-28 requests rejected as unsupported |
Relying on ping for health | server/discover | -32601 Method not found |
The Roots, Sampling and Logging features are deprecated rather than removed, with at least a twelve-month window under the new lifecycle policy, so they are not on the critical path. The two rows that bite in production are the shared requestState key and the TypeScript opt-in: both leave a server that looks healthy and passes its own smoke test while every 2026-era client fails. If your servers feed an agent, context engineering for AI agents covers what to do with the deterministic, cacheable tool lists the new ttlMs and cacheScope fields enable.