The takeaway up front: The 2026-07-28 MCP spec deprecates three features — Sampling, Roots, and Logging — but nothing breaks on July 28. They're annotation-only deprecations, guaranteed to keep working in every spec version published within a year, so you have a 12-month minimum runway. The migrations are small, and you can do all three in one SDK-upgrade pass. Here's the replacement code for each.
First: are you even affected?#
Most MCP servers use only Tools and Resources and touch none of the three deprecated features. Grep your server for the giveaways before you plan anything:
# If none of these match, you're done — just bump the SDK.
grep -rEn "sampling/createMessage|createMessage|rootsChanged|listRoots|roots" src/
grep -rEn "notifications/message|logging/setLevel|sendLoggingMessage" src/
If you get hits, keep reading for the one that applies. If you don't, skip to the While you're in there section below and just go stateless.
Roots → explicit tool parameters, resource URIs, or config#
What Roots did: the client advertised a set of root directories your server was allowed to operate in, and your server queried them at runtime. It was an implicit, client-driven boundary.
The replacement: make the scope an explicit input. Three options, in order of preference — a tool parameter, a resource URI, or server configuration.
// BEFORE — server asks the client which roots it may use
const { roots } = await server.listRoots();
const base = roots[0].uri; // implicit, client-controlled
const files = await readDir(base);
// AFTER — the working directory is an explicit tool parameter
server.tool("list_project_files", {
path: z.string().describe("Absolute path to the project directory"),
}, async ({ path }) => {
assertAllowed(path); // you validate against config
return { files: await readDir(path) };
});
If a path shouldn't be caller-supplied, read it from configuration instead — an env var or a config file your server owns — so the boundary is auditable in one place rather than negotiated per session.
Sampling → call the LLM provider directly#
What Sampling did: your server sent sampling/createMessage and the client ran a model completion on the server's behalf. Convenient, but you had no control over which model, whose keys, or the cost.
The replacement: the server calls the provider API itself (or the host injects a model client). You own the model choice, the key, retries, and the bill.
// BEFORE — ask the client to sample for you
const result = await server.createMessage({
messages: [{ role: "user", content: { type: "text", text: prompt } }],
maxTokens: 512,
});
const summary = result.content.text;
// AFTER — call the provider directly with your own client
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // your key, your model policy
const msg = await client.messages.create({
model: "claude-opus-4-8",
max_tokens: 512,
messages: [{ role: "user", content: prompt }],
});
const summary = msg.content[0].text;
This is strictly more control, not less: model selection, cost caps, and error handling are now yours instead of whatever the connecting client happened to expose.
Logging → stderr and OpenTelemetry#
What Logging did: your server emitted protocol-level notifications/message log events over the transport. It coupled your observability to the MCP connection.
The replacement: standard channels. On a stdio transport, write to stderr — stdout is reserved for protocol frames, so logging there corrupts the stream. For structured, queryable telemetry, emit OpenTelemetry spans.
// BEFORE — protocol-level logging notification
server.sendLoggingMessage({ level: "info", data: "processing request" });
// AFTER — stderr for stdio servers (never stdout on stdio transport)
console.error(JSON.stringify({ level: "info", msg: "processing request", ts: Date.now() }));
// AFTER — OpenTelemetry for structured observability
import { trace } from "@opentelemetry/api";
const tracer = trace.getTracer("my-mcp-server");
await tracer.startActiveSpan("handle_request", async (span) => {
span.setAttribute("tool.name", name);
try { /* ... */ } finally { span.end(); }
});
Now your logs land in the same pipeline as the rest of your infrastructure instead of a protocol side-channel only the connected client can see.
While you're in there: go stateless#
The same 2026-07-28 release makes the MCP core stateless — it removes the initialize/initialized handshake and the Mcp-Session-Id header that pinned a client to one server instance. Protocol version, client info, and capabilities now ride inline in a _meta field on each request, and new Mcp-Method / Mcp-Name headers let a load balancer route without reading the body. So a remote server can sit behind a plain round-robin balancer with no sticky sessions.
If you're already touching the SDK to migrate off the three deprecated features, drop your session-pinning assumptions in the same pass:
// BEFORE — state keyed on a session that no longer exists
const state = sessions.get(req.headers["mcp-session-id"]);
// AFTER — state keyed on an explicit, caller-provided handle
const state = store.get(req.params.handle); // durable, instance-independent
We covered the stateless core in depth in MCP Goes Stateless — this how-to is the migration companion to it.
The one-pass checklist#
- [ ] Upgrade the SDK to the 2026-07-28 beta and read its migration notes.
- [ ] Roots: replace
listRoots()with explicit tool parameters, resource URIs, or config; validate paths server-side. - [ ] Sampling: replace
createMessage()with a direct provider client you control. - [ ] Logging: send logs to stderr (stdio) and OpenTelemetry (structured); never log to stdout on a stdio transport.
- [ ] Stateless: drop
Mcp-Session-Idassumptions; key state on explicit handles; emitMcp-Method/Mcp-Name. - [ ] Ship on your schedule. Nothing breaks for at least 12 months — but doing it now, while the SDK is fresh in your head, is cheaper than doing it under a removal deadline.



