MCP 2026-07-28 removes protocol-level sessions, the required initialization handshake, and Mcp-Session-Id. Before deleting those code paths, find the application state they were hiding. This tutorial builds a dependency scanner, classifies each match by migration risk, and turns the results into a staged rollout checklist.
This is a static audit, not proof that a deployment is compatible. It finds likely legacy assumptions; you still need runtime tests across clients, servers, gateways, and replicas.
What changed?
The official MCP 2026-07-28 changelog makes several changes that affect production architecture:
-
Mcp-Session-Idis removed from Streamable HTTP. -
initializeandnotifications/initializedare no longer required. - Protocol version and client capabilities travel in each request’s
_meta. - Servers implement
server/discoverfor version and capability discovery. - Multi Round-Trip Requests replace server-initiated requests.
-
Mcp-MethodandMcp-Namesupport HTTP routing and policy. - Cacheable list/read results include
ttlMsandcacheScope. - Roots, Sampling, Logging, HTTP+SSE, selected
includeContextvalues, and Dynamic Client Registration are deprecated rather than immediately removed.
The risky misunderstanding is that “stateless protocol” means “the application has no state.” A workspace, durable task, OAuth credential, or pending approval can still span calls. The dependency must simply be explicit and available to any compatible worker that receives the next request.
Step 1: Build a zero-dependency scanner
The following Node.js script searches a repository for older MCP assumptions. It uses only Node’s standard library and produces both a readable report and an optional JSON report for CI.
Create audit-mcp-2026.mjs:
#!/usr/bin/env node
import { promises as fs } from "node:fs";
import path from "node:path";
import process from "node:process";
const rules = [
{
id: "protocol-session",
risk: "high",
pattern: /Mcp-Session-Id|ctx\.sessionId|extra\.sessionId|sessionId/g,
action: "Map the state behind the session, then use an explicit handle or shared storage.",
},
{
id: "initialization-gate",
risk: "medium",
pattern: /notifications\/initialized|\binitialize\b/g,
action: "Remove gates for modern requests and add server/discover plus version negotiation.",
},
{
id: "sticky-routing",
risk: "high",
pattern: /sticky[_-]?session|sessionAffinity|affinity:\s*(?:ClientIP|cookie)/gi,
action: "Test consecutive calls on different replicas before removing affinity.",
},
{
id: "server-initiated-request",
risk: "high",
pattern: /elicitation\/create|sampling\/createMessage|roots\/list/g,
action: "Move interaction to MRTR and make retried side effects idempotent.",
},
{
id: "legacy-resource-subscription",
risk: "medium",
pattern: /resources\/(?:subscribe|unsubscribe)|Last-Event-ID/g,
action: "Plan subscriptions/listen and test broken-stream behavior.",
},
{
id: "legacy-task",
risk: "medium",
pattern: /tasks\/(?:result|list)/g,
action: "Adopt the io.modelcontextprotocol/tasks extension contract.",
},
{
id: "legacy-logging",
risk: "low",
pattern: /logging\/setLevel|notifications\/message/g,
action: "Review per-request log metadata and production observability.",
},
];
const ignoredDirectories = new Set([
".git",
"node_modules",
"dist",
"build",
"coverage",
"vendor",
]);
const textExtensions = new Set([
".cjs", ".conf", ".go", ".java", ".js", ".json", ".jsx",
".md", ".mjs", ".py", ".rb", ".rs", ".sh", ".toml",
".ts", ".tsx", ".yaml", ".yml",
]);
function parseArguments(argv) {
const result = { root: ".", json: false };
for (const argument of argv) {
if (argument === "--json") result.json = true;
else if (argument.startsWith("-")) {
throw new Error(`Unknown option: ${argument}`);
} else result.root = argument;
}
return result;
}
async function* walk(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (ignoredDirectories.has(entry.name)) continue;
const fullPath = path.join(directory, entry.name);
if (entry.isDirectory()) yield* walk(fullPath);
else if (entry.isFile() && textExtensions.has(path.extname(entry.name))) {
yield fullPath;
}
}
}
function scanLine(line, file, lineNumber) {
const findings = [];
for (const rule of rules) {
rule.pattern.lastIndex = 0;
const matches = [...line.matchAll(rule.pattern)];
for (const match of matches) {
findings.push({
file,
line: lineNumber,
column: (match.index ?? 0) + 1,
match: match[0],
rule: rule.id,
risk: rule.risk,
action: rule.action,
});
}
}
return findings;
}
async function audit(root) {
const absoluteRoot = path.resolve(root);
const findings = [];
for await (const file of walk(absoluteRoot)) {
const content = await fs.readFile(file, "utf8");
const lines = content.split(/\r?\n/);
lines.forEach((line, index) => {
findings.push(
...scanLine(line, path.relative(absoluteRoot, file), index + 1),
);
});
}
return findings;
}
function printText(findings) {
if (findings.length === 0) {
console.log("No known legacy MCP patterns found. Runtime testing is still required.");
return;
}
const weight = { high: 0, medium: 1, low: 2 };
findings.sort((a, b) => weight[a.risk] - weight[b.risk]);
for (const finding of findings) {
console.log(
`[${finding.risk.toUpperCase()}] ${finding.file}:${finding.line}:${finding.column} ` +
`${finding.rule} (${finding.match})`,
);
console.log(` ${finding.action}`);
}
const counts = findings.reduce(
(summary, item) => ({ ...summary, [item.risk]: summary[item.risk] + 1 }),
{ high: 0, medium: 0, low: 0 },
);
console.log(`\nSummary: ${counts.high} high, ${counts.medium} medium, ${counts.low} low`);
}
async function main() {
const options = parseArguments(process.argv.slice(2));
const findings = await audit(options.root);
if (options.json) console.log(JSON.stringify({ findings }, null, 2));
else printText(findings);
process.exitCode = findings.some((item) => item.risk === "high") ? 2 : 0;
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
Step 2: Run the audit
The script requires Node.js 20 or later. Run it from any directory and pass the repository path:
node audit-mcp-2026.mjs /path/to/your/repository
Use JSON output in CI or to create migration tickets:
node audit-mcp-2026.mjs /path/to/your/repository --json > mcp-audit.json
The exit codes are:
| Code | Meaning |
|---|---|
0 |
No high-risk static match was found |
1 |
The scanner itself failed |
2 |
At least one high-risk pattern was found |
Do not fail a production release merely because a string appears in documentation or a compatibility test. Treat each finding as a review item. The script intentionally favors visibility over certainty.
Step 3: Classify every match by state ownership
A grep result is not a migration plan. For each high-risk match, record:
- What state exists? Workspace, credential, user preference, approval, task, cursor, or cache entry.
- Who owns it? User, tenant, request, tool, server, or deployment.
- How long must it survive? One retry, several calls, a process restart, or days.
- Who may read or change it? The authorization boundary belongs in the design.
- Can two workers process it safely? If not, the migration is incomplete.
- Can a retry duplicate a side effect? Add staging and idempotency before enabling MRTR.
The common replacement patterns are explicit handles and shared storage. An explicit handle makes the dependency visible in the tool schema. Shared storage makes the state accessible across replicas and restarts. Long-running work belongs in a durable task system rather than the memory of a request worker.
Step 4: Test the new failure model
Static scanning cannot detect whether a retry is safe or whether an authorization boundary is correct. Add integration tests that deliberately exercise the new architecture.
Cross-replica state test
- Send a request that creates a workspace or task.
- Force the next request to a different worker.
- Pass only the documented handle and authenticated identity.
- Confirm that the second worker can continue the operation.
- Restart the first worker and repeat.
MRTR retry test
- Start a destructive or billable operation.
- Confirm that the first response is
input_requiredbefore the side effect. - Tamper with and expire
requestState; both cases must fail closed. - Retry twice with the same business idempotency key.
- Confirm that exactly one side effect occurs.
Gateway mismatch test
Send a request whose Mcp-Name identifies a low-risk tool while the JSON-RPC body calls a restricted tool. The request should be rejected and the mismatch should appear in security telemetry.
Cache isolation test
Fetch a private tool or resource list as tenant A, then request the same method as tenant B. The second tenant must never receive A’s entry, regardless of the remaining TTL.
Step 5: Canary the protocol, not just the package
A safe rollout keeps modern and legacy behavior observable at the same time. Test these combinations explicitly:
modern client + modern server -> MCP 2026-07-28
modern client + legacy server -> tested fallback, if supported
legacy client + dual server -> legacy path
unsupported combination -> clear protocol-version error
Track protocol version, discovery success, fallback rate, missing or invalid headers, header/body mismatches, MRTR completion and timeout, request-state verification, duplicate-operation prevention, cache hits by scope, OAuth issuer failures, legacy HTTP+SSE traffic, and deprecated-method traffic.
Retire a compatibility path because the telemetry says it is unused—not because a calendar says the upgrade should be complete.
Where does a model gateway fit?
MCP and a model API solve different problems. MCP connects an agent to tools, resources, approvals, and tasks. A model API connects an application to inference providers, credentials, usage, and billing.
When migrating away from the deprecated Sampling capability, an application can call a model provider directly or use a unified model layer such as CometAPI. That model layer does not replace MCP authorization, tool contracts, application state, or MRTR handling. Keeping the interfaces separate simply allows the protocol and inference providers to evolve independently.
Migration checklist
- [ ] Run the static audit across clients, servers, gateways, and deployment config.
- [ ] Map every session lookup to its actual business state.
- [ ] Implement
server/discoverand version negotiation. - [ ] Include protocol metadata on each modern request.
- [ ] Replace hidden state with handles, shared storage, or durable tasks.
- [ ] Secure and expire MRTR
requestState. - [ ] Make side effects idempotent across retries.
- [ ] Validate MCP routing headers against the JSON-RPC body.
- [ ] Partition caches by authorization boundary.
- [ ] Test OAuth issuer changes and credential storage.
- [ ] Canary modern and legacy traffic with rollback.
- [ ] Retire deprecated behavior only after measuring its use.
Final takeaway
MCP 2026-07-28 removes sessions from the protocol contract, not from every application requirement. The migration succeeds when any compatible worker can process the next request from explicit inputs, every retry is authorized and idempotent, and the legacy path can be retired from evidence.
Top comments (1)
I like that you distinguish protocol state from application state. One additional question I’d ask during migration is: “What business invariant was the session accidentally enforcing?” Sometimes a session isn’t just carrying state—it is implicitly guaranteeing ownership, ordering, or authorization. Replacing it with an explicit handle is only half the migration; preserving those invariants across retries and replicas is the harder part. That’s usually where subtle production bugs appear.