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 stderrstdout 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#