If you are migrating an MCP server right now, here is the thing that will cost you
an afternoon, before anything else in this post:
@modelcontextprotocol/sdk has no 2.x. It never will.
It stops at 1.30.0. If you go looking for @modelcontextprotocol/sdk@^2 you
will find nothing and conclude that v2 has not shipped yet. It has — on
2026-07-27, under different names:
@modelcontextprotocol/server 2.0.0
@modelcontextprotocol/client 2.0.0
@modelcontextprotocol/core 2.0.0
@modelcontextprotocol/node 2.0.0
@modelcontextprotocol/express 2.0.0 ┐
@modelcontextprotocol/fastify 2.0.0 ├ HTTP adapters
@modelcontextprotocol/hono 2.0.0 ┘
@modelcontextprotocol/server-legacy 2.0.0 compat shim
@modelcontextprotocol/codemod 2.0.0
Enter fullscreen mode Exit fullscreen mode
I know this because my own tool told people the opposite, with total confidence.
What broke
The MCP revision dated 2026-07-28 is the largest change the protocol has had.
The short version:
- The transport is stateless. The
initialize/notifications/initializedhandshake is gone. Every request now carries its protocol version and client capabilities in_meta. -
Mcp-Session-Idis gone from Streamable HTTP. There are no protocol-level sessions any more. -
server/discoveris a MUST. Servers have to advertise their supported protocol versions and capabilities over it. - Server-initiated requests (
roots/list,sampling/createMessage,elicitation/create) are replaced by Multi Round-Trip Requests: the server returns anInputRequiredResult, the client retries with the answers. -
logging,samplingandrootsare deprecated, not removed - twelve-month minimum window.
The second point is the one that hurts. If you keep anything in a Map keyed by
session id, it works perfectly on your laptop with one process and fails
intermittently the moment two instances sit behind a load balancer. It fails
quietly, which is the worst way to fail.
So I built a checker: seven rules, a deterministic engine, no model in the loop.
Point it at a running endpoint or a repository and it tells you what breaks, with
the spec page for each finding so you can prove it wrong. It ships as an agent
skill that does the migration too, which I will come back to — because the
scanner on its own turned out to be the smaller half.
Then my own rule was wrong
One rule, MCP007, fired when @modelcontextprotocol/sdk resolved below 2.0.0
and said:
Upgrade to
@modelcontextprotocol/sdk ^2. Run the official v1→v2 codemod for
the mechanical renames, then re-check.
Both halves of that are wrong, and they are wrong in different ways.
@modelcontextprotocol/sdk@^2 does not resolve. There has never been a 2.x of
that package. So the tool was confidently recommending a version that does not
exist - to people who would then spend twenty minutes wondering what they were doing wrong.
I checked npm, found 1.30.0 as the latest and no 2.x anywhere in the version
list, read the SDK announcement, saw no major version named, and concluded the
rule was fabricated wholesale. So I deleted it, wrote a comment explaining that no
2.x line existed and no codemod existed, and - this is the part that stings -
propagated that "correction" into the README, the skill and the remediation guide.
I had replaced a wrong statement with a differently wrong statement.
Why the second mistake was so easy
Look at what I actually checked. I checked the package the rule named. It ends
at 1.30.0. From inside that one package, these two situations are indistinguishable:
there is no v2 yet
v2 exists under a different name
Enter fullscreen mode Exit fullscreen mode
A rename produces exactly the evidence you would expect from absence. Checking
harder in the same place does not help — the answer is not there. I found it only
when I went looking for the codemod, which turned out to exist as its own
package, and whose description read "Codemod to migrate MCP TypeScript SDK code from v1 to v2". A codemod for a v2 that does not exist would be a strange thing to publish.
The rule now keys on the presence of the v1 package rather than on a version
threshold, because the package name is the actual signal:
// The package name IS the v1 line. It stops at 1.30.0 and speaks the
// pre-2026-07-28 protocol. v2 shipped under different names entirely,
// so a version comparison here is meaningless.
if (!ctx.source?.sdkVersion) return null;
Enter fullscreen mode Exit fullscreen mode
And a test now pins the first mistake down permanently:
test("MCP007 names the real replacement packages and the real codemod", () => {
const f = evaluate({ source: withSdk("^1.17.0") })
.find((x) => x.ruleId === "MCP007");
assert.ok(f.fix.includes("@modelcontextprotocol/server"));
assert.ok(f.fix.includes("@modelcontextprotocol/client"));
assert.ok(f.fix.includes("@modelcontextprotocol/codemod@latest v1-to-v2"));
assert.ok(
!/@modelcontextprotocol\/sdk[@^ ]*\^?2/.test(f.fix),
"must not advise upgrading @modelcontextprotocol/sdk to 2.x — no such release",
);
});
Enter fullscreen mode Exit fullscreen mode
The positive assertions matter as much as the negative one. A test that only
forbids the wrong package name would pass on a rule that names nothing at all —
which is roughly what my deletion produced.
The other thing I found while I was in there
Every rule cites a spec section, so a user can check the tool rather than trust it.
All seven of those links looked like this:
https://modelcontextprotocol.io/specification/2026-07-28#lifecycle
https://modelcontextprotocol.io/specification/2026-07-28#transport
https://modelcontextprotocol.io/specification/2026-07-28#authorization
Enter fullscreen mode Exit fullscreen mode
The specification is split across subpages. It has no such anchors. Every one of
those links resolved silently to the overview page — no 404, no broken-link
warning, just a page that is not the one being cited. A "verify this against the
spec" feature that quietly cannot be verified is worse than not having it, because
it buys credibility it has not earned.
There is now a test for that too:
test("no specRef relies on a page anchor", () => {
for (const rule of rules) {
assert.ok(rule.specRef.startsWith("https://"));
assert.ok(
!rule.specRef.includes("#"),
`${rule.id} specRef relies on an anchor: ${rule.specRef}`,
);
}
});
Enter fullscreen mode Exit fullscreen mode
A second one asserts that any link into the spec site points at a subpage rather
than the revision root, so a future rule cannot quietly cite the front page again.
The part that generalises
Two things I would take to any project, MCP or not.
Deterministic is not the same as correct. The whole selling point of this
engine is that it is reproducible: same input, same output, no model deciding
things differently on Tuesday. That is a real property and it is worth having.
It also gave me a rule that was reliably, repeatably, verifiably wrong. Determinism
buys you the ability to argue with the output. It does not buy you truth.
"No result" and "you looked in the wrong place" produce identical evidence.
This is the one I keep thinking about. Absence of a thing at the address you
checked is not absence of the thing. The rename is a clean example, but the shape
turns up everywhere: a config key that moved, an endpoint that was versioned, a
function that was extracted to another module. When a check comes back negative and the negative result is surprising, the next question should be "am I looking in
the right place" before "so it does not exist".
I write more agent-driven code than I used to, and the failure mode has shifted
accordingly. It is no longer mostly syntax. It is confident, plausible, internally
consistent statements about an ecosystem, that happen not to be true. The defence is not to trust less, it is to make claims checkable and then actually check them — which is why every rule cites a page, and why the links being broken mattered more than it first looked.
Why the scanner is the smaller half
Here is the thing I got wrong about my own tool before I got the SDK wrong.
The source scan is regex-based. It reports signals, not proof. Point an agent at
the output and tell it to fix what it finds, and sooner or later it will hit
something like this:
app.use(session({ secret: process.env.ADMIN_SESSION_SECRET }));
app.get("/admin/whoami", (req, res) => {
const sessionId = req.sessionID; // ← MCP002 fires here
res.json({ sessionId, user: req.session.user });
});
app.post("/mcp", async (req, res) => {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined, // ← already stateless. Nothing to do.
});
// …
});
Enter fullscreen mode Exit fullscreen mode
That is an Express session for an admin panel, on a server whose MCP transport is
already stateless. The rule is right that the string is there. Acting on it means
refactoring something that was never broken — and a change nobody needed is more expensive than the finding was worth.
So what actually ships is a procedure, not a scanner:
- Diagnose — run the bundled deterministic checker, both source scan and live probe where a server is running. They see different things and neither is a superset of the other.
-
Triage — justify every finding at its
file:linebefore touching anything, and say out loud which ones are noise. This step exists because of the code above. - Remediate — in dependency order: SDK first (so you refactor against the API you keep), then session state, then the handshake, then the deprecations, then the OAuth posture. Re-run after each, so you can tell which change fixed what.
- Verify — including the thing no static check can answer: does the server still behave when two consecutive requests land on different instances?
The rule ids are what ties it together. The checker emits MCP002, and the
remediation guide has a section keyed MCP002 explaining how to tell a real
session dependency from an Express one. Diagnosis and fix are not two documents
that drift.
It is a Claude skill. The payload is not Claude-specific.
Worth being precise about, because "works with every agent" is the kind of
confident claim this whole post is about not making.
The .skill format — a SKILL.md with frontmatter that an agent auto-invokes when the topic comes up — is Claude's. In Claude Code or Claude.ai you install the file and ask it to migrate a server; the description field does the rest.
The contents are not Claude's. A .skill file is a zip:
mcp-migration/
├── SKILL.md the procedure, plain markdown
├── references/remediation.md per-rule guidance, keyed by id
└── scripts/mcpcheck.mjs the engine, one file, zero dependencies
Enter fullscreen mode Exit fullscreen mode
mcpcheck.mjs is an esbuild bundle of the rule engine that needs nothing but
Node. So for Codex, Cursor, or anything else that can run a shell command:
unzip mcp-migration.skill
node mcp-migration/scripts/mcpcheck.mjs --source ./my-server --json
node mcp-migration/scripts/mcpcheck.mjs https://example.com/mcp
Enter fullscreen mode Exit fullscreen mode
Exit codes are CI-friendly: 0 no critical findings, 1 at least one, 2
inconclusive. references/remediation.md reads fine on its own, or drop it next to
an AGENTS.md so whatever agent you use has the same triage rules Claude gets.
The reason it is bundled rather than published as a dependency is exactly this: the
skill lands on machines where no npm install ever ran, and an import would just
fail there. The cost is a generated file committed to the repo, which can go stale
— so CI rebuilds it on every push and fails if it differs.
The practical bit, if you are migrating
The codemod is real and handles the mechanical half:
npx @modelcontextprotocol/codemod@latest v1-to-v2 .
Enter fullscreen mode Exit fullscreen mode
Run it on a clean tree. It rewrites import paths, symbol renames
(McpError → ProtocolError, StreamableHTTPError → SdkHttpError),
setRequestHandler(Schema, …) → setRequestHandler('method/string', …),
.tool() → registerTool, and extra.* → ctx.mcpReq.*. Where it cannot decide
safely it leaves a marker instead of guessing:
grep -rn '@mcp-codemod-error' .
Enter fullscreen mode Exit fullscreen mode
Note that v2 requires Zod 4, so a project on Zod 3 has a second upgrade in
front of it.
And then the part that is not mechanical, in the codemod's own words:
The codemod handles the v1→v2 SDK surface upgrade only. Adopting the 2026-07-28
protocol revision (createMcpHandler, multi-round-trip requests,
versionNegotiation) is architectural and not codemod-automatable.
That is accurate and worth taking seriously. Removing session state is a design
decision — delete it, pass it as an explicit tool argument, or move it to a store
both instances can reach. No tool makes that call for you, and the deprecated
capabilities can wait twelve months while you do.
All of it is open source, MIT:
- Hosted checker — paste an endpoint, get a graded report. Nothing to install, nothing stored.
- Download the skill — install it in Claude, or unzip it and run the checker from any agent.
- Source — 86 tests, mostly pointed at the SSRF guard, since the hosted version fetches a URL a stranger typed in.
The README has a section called A rule that was wrong. It says roughly what this
post says, in fewer words, and it stays there.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.