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
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
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;
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",
);
});
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
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}`,
);
}
});
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.
});
// …
});
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
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
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 .
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' .
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.
Top comments (6)
The positive assertion point is excellent. I would take it one step further and make each remediation rule carry a machine-verifiable evidence bundle: source URL, retrieval timestamp, observed package/version set, expected replacement set, and a fixture copied from an actual migrated repository. Then CI can replay the rule against a frozen ecosystem snapshot and a periodically refreshed one. That separates “the checker logic regressed” from “the ecosystem moved.” For spec links, a 200 response is not enough when a site silently redirects to an overview; assert the final canonical URL plus the presence of a stable section identifier or expected heading. Deterministic checks become much more trustworthy when their claims have versioned provenance and expiry, not just repeatable code.
The 200-is-not-enough point is exactly right, and I have the receipt: the broken
spec links returned 200.
…/2026-07-28#lifecycleresolves fine — the serverserves the overview page and the fragment silently matches nothing. A link checker
would have passed all seven. That is why the test I ended up with asserts the
shape of the URL rather than its reachability, which is a weaker guarantee than
I would like.
Provenance with expiry is the right frame, and it separates two kinds of claim I
had been treating as one. "The transport removed Mcp-Session-Id" is anchored to a
dated revision and does not rot. "The SDK has no 2.x" is a statement about a
registry at a moment in time, and it will be wrong eventually — probably without
anyone noticing, which is the failure mode that started this whole thing.
Concretely, what I think is worth building from your list:
verifiedAton each rule, surfaced in the output. A finding that says "checked against the spec on 2026-08-01" is honest in a way an undated one is not.The fixture from a real migrated repo is the expensive one and I do not have it.
Mine are hand-written, which means they encode what I expect rather than what
actually happened to somebody.
The failure generalizes past MCP: a registry lookup cannot distinguish "does not exist yet" from "moved", because absence is the same observation in both cases. The only reliable disambiguator is a tombstone on the old name - a deprecation notice that names the successor - and packages that rename without leaving one produce exactly the confident wrongness your checker had. The skill angle is the part I keep thinking about: a skill that encodes "sdk stops at 1.30.0, use these nine packages" is correct today and silently wrong after the next rename, and unlike your checker it has no runtime to notice. Skills that assert facts about external registries probably need a "verify against the live registry before asserting absence" instruction baked in, so the agent re-derives the claim instead of trusting the snapshot. Did you end up making the checker read the deprecation metadata on the old package as the successor pointer, and what does it do when the rename left no pointer at all?
Checked, since it decides the answer: there is no tombstone.
No deprecated field on any of the 79 versions. The old README does carry one
forward pointer — a "V2 API reference" link — but it sits at line 161 and does not
name the successor packages. The clear statement is on the new package, first
paragraph: "It replaces the monolithic @modelcontextprotocol/sdk package from v1."
Which is the shape of the problem. The pointer exists in the direction you can only
follow once you have already arrived.
To your question: no, the checker does not read deprecation metadata, and after
this it probably should. The uncomfortable part is that it would not have helped
here — there was nothing to read. For a rename that leaves no pointer at all I do
not think a lookup can save you. What can is the thing that eventually worked on
me: treating a surprising negative as a signal about the question rather than
the answer. I found it by going after the codemod, not the package. A codemod for
a v2 that does not exist would be an odd thing to publish.
Same trap here, cheaper version: a skill of mine hardcoded config keys that turned out hallucinated, and a reader caught it before I did. A checker can be wrong in public, but a static skill just ages into wrong with nobody home. Did the deprecation notice give you a tombstone to key on, or does a nameless rename still slip through?
"A checker can be wrong in public, but a static skill just ages into wrong" is a
better formulation of the problem than anything in my post, and it names a
weakness I still have.
On the tombstone: there is none. No deprecated field on @modelcontextprotocol/sdk,
79 versions, nothing. The old README links to v2 API docs at line 161 without
naming the new packages; the unambiguous notice is on the new package, where it
only helps people who already found it.
The aging problem is only half-solved on my side. The rule engine is bundled into
the skill and rebuilt by CI on every push, so the executable part cannot silently
drift from the source. The prose next to it can, and does — my remediation guide
contains sentences like "the latest published SDK is 1.x", which is a fact with a
shelf life sitting in a document with no expiry date. Mads made a related point
above about provenance, and I think dating those claims in the file is the minimum.
Sorry your reader had to catch the config keys. That is the same failure with a
worse audience.