# I built a Skill and checker for MCP's breaking change 2026-08-26. Then the checker was wrong about it.

> Source: <https://dev.to/vertigo91/i-built-a-skill-and-checker-for-mcps-breaking-change-2026-08-26-then-the-checker-was-wrong-about-1kb4>
> Published: 2026-08-02 01:41:26+00:00

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.

The MCP revision dated **2026-07-28** is the largest change the protocol has had.

The short version:

`initialize`

/ `notifications/initialized`

handshake is gone. Every request now carries its protocol version and client
capabilities in `_meta`

.`Mcp-Session-Id`

is gone`server/discover`

is a MUST.`roots/list`

, `sampling/createMessage`

,
`elicitation/create`

) are replaced by `InputRequiredResult`

, the client retries with the answers.`logging`

, `sampling`

and `roots`

are 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.

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.

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:

``` js
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.

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:

``` js
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.

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.

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:

`file:line`

before touching anything,
and say out loud which ones are noise. This step exists because of the code
above.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.

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 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:

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.
