{"slug": "i-built-a-skill-and-checker-for-mcp-s-breaking-change-2026-08-26-then-the-was-it", "title": "I built a Skill and checker for MCP's breaking change 2026-08-26. Then the checker was wrong about it.", "summary": "A developer built a seven-rule checker for the Model Context Protocol's breaking change of 2026-07-28, but one rule gave wrong advice because it checked the version of @modelcontextprotocol/sdk, which stops at 1.30.0 and has no 2.x. The v2 packages shipped under different names, such as @modelcontextprotocol/server and @modelcontextprotocol/client, so the checker's recommendation to upgrade to @modelcontextprotocol/sdk ^2 was impossible. The developer initially deleted the rule and propagated a wrong correction, but later discovered the codemod package and fixed the rule to key on the presence of the v1 package name.", "body_md": "If you are migrating an MCP server right now, here is the thing that will cost you\n\nan afternoon, before anything else in this post:\n\n`@modelcontextprotocol/sdk`\n\nhas no 2.x. It never will.\n\nIt stops at `1.30.0`\n\n. If you go looking for `@modelcontextprotocol/sdk@^2`\n\nyou\n\nwill find nothing and conclude that v2 has not shipped yet. It has — on\n\n2026-07-27, under different names:\n\n```\n@modelcontextprotocol/server         2.0.0\n@modelcontextprotocol/client         2.0.0\n@modelcontextprotocol/core           2.0.0\n@modelcontextprotocol/node           2.0.0\n@modelcontextprotocol/express        2.0.0   ┐\n@modelcontextprotocol/fastify        2.0.0   ├ HTTP adapters\n@modelcontextprotocol/hono           2.0.0   ┘\n@modelcontextprotocol/server-legacy  2.0.0   compat shim\n@modelcontextprotocol/codemod        2.0.0\n```\n\nI know this because my own tool told people the opposite, with total confidence.\n\nThe MCP revision dated **2026-07-28** is the largest change the protocol has had.\n\nThe short version:\n\n`initialize`\n\n/ `notifications/initialized`\n\nhandshake is gone. Every request now carries its protocol version and client\ncapabilities in `_meta`\n\n.`Mcp-Session-Id`\n\nis gone`server/discover`\n\nis a MUST.`roots/list`\n\n, `sampling/createMessage`\n\n,\n`elicitation/create`\n\n) are replaced by `InputRequiredResult`\n\n, the client retries with the answers.`logging`\n\n, `sampling`\n\nand `roots`\n\nare The second point is the one that hurts. If you keep anything in a `Map`\n\nkeyed by\n\nsession id, it works perfectly on your laptop with one process and fails\n\nintermittently the moment two instances sit behind a load balancer. It fails\n\nquietly, which is the worst way to fail.\n\nSo I built a checker: seven rules, a deterministic engine, no model in the loop.\n\nPoint it at a running endpoint or a repository and it tells you what breaks, with\n\nthe spec page for each finding so you can prove it wrong. It ships as an agent\n\nskill that does the migration too, which I will come back to — because the\n\nscanner on its own turned out to be the smaller half.\n\nOne rule, MCP007, fired when `@modelcontextprotocol/sdk`\n\nresolved below `2.0.0`\n\nand said:\n\nUpgrade to\n\n`@modelcontextprotocol/sdk ^2`\n\n. Run the official v1→v2 codemod for\n\nthe mechanical renames, then re-check.\n\nBoth halves of that are wrong, and they are wrong in different ways.\n\n`@modelcontextprotocol/sdk@^2`\n\ndoes not resolve. There has never been a 2.x of\n\nthat package. So the tool was confidently recommending a version that does not\n\nexist - to people who would then spend twenty minutes wondering what they were doing wrong.\n\nI checked npm, found `1.30.0`\n\nas the latest and no 2.x anywhere in the version\n\nlist, read the SDK announcement, saw no major version named, and concluded the\n\nrule was fabricated wholesale. So I deleted it, wrote a comment explaining that no\n\n2.x line existed and no codemod existed, and - this is the part that stings -\n\npropagated that \"correction\" into the README, the skill and the remediation guide.\n\nI had replaced a wrong statement with a differently wrong statement.\n\nLook at what I actually checked. I checked *the package the rule named*. It ends\n\nat 1.30.0. From inside that one package, these two situations are indistinguishable:\n\n```\nthere is no v2 yet\nv2 exists under a different name\n```\n\nA rename produces exactly the evidence you would expect from absence. Checking\n\nharder in the same place does not help — the answer is not there. I found it only\n\nwhen I went looking for the *codemod*, which turned out to exist as its own\n\npackage, 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.\n\nThe rule now keys on the presence of the v1 package rather than on a version\n\nthreshold, because the package name is the actual signal:\n\n```\n// The package name IS the v1 line. It stops at 1.30.0 and speaks the\n// pre-2026-07-28 protocol. v2 shipped under different names entirely,\n// so a version comparison here is meaningless.\nif (!ctx.source?.sdkVersion) return null;\n```\n\nAnd a test now pins the first mistake down permanently:\n\n``` js\ntest(\"MCP007 names the real replacement packages and the real codemod\", () => {\n  const f = evaluate({ source: withSdk(\"^1.17.0\") })\n    .find((x) => x.ruleId === \"MCP007\");\n\n  assert.ok(f.fix.includes(\"@modelcontextprotocol/server\"));\n  assert.ok(f.fix.includes(\"@modelcontextprotocol/client\"));\n  assert.ok(f.fix.includes(\"@modelcontextprotocol/codemod@latest v1-to-v2\"));\n  assert.ok(\n    !/@modelcontextprotocol\\/sdk[@^ ]*\\^?2/.test(f.fix),\n    \"must not advise upgrading @modelcontextprotocol/sdk to 2.x — no such release\",\n  );\n});\n```\n\nThe positive assertions matter as much as the negative one. A test that only\n\nforbids the wrong package name would pass on a rule that names nothing at all —\n\nwhich is roughly what my deletion produced.\n\nEvery rule cites a spec section, so a user can check the tool rather than trust it.\n\nAll seven of those links looked like this:\n\n```\nhttps://modelcontextprotocol.io/specification/2026-07-28#lifecycle\nhttps://modelcontextprotocol.io/specification/2026-07-28#transport\nhttps://modelcontextprotocol.io/specification/2026-07-28#authorization\n```\n\nThe specification is split across subpages. It has no such anchors. Every one of\n\nthose links resolved silently to the overview page — no 404, no broken-link\n\nwarning, just a page that is not the one being cited. A \"verify this against the\n\nspec\" feature that quietly cannot be verified is worse than not having it, because\n\nit buys credibility it has not earned.\n\nThere is now a test for that too:\n\n``` js\ntest(\"no specRef relies on a page anchor\", () => {\n  for (const rule of rules) {\n    assert.ok(rule.specRef.startsWith(\"https://\"));\n    assert.ok(\n      !rule.specRef.includes(\"#\"),\n      `${rule.id} specRef relies on an anchor: ${rule.specRef}`,\n    );\n  }\n});\n```\n\nA second one asserts that any link into the spec site points at a subpage rather\n\nthan the revision root, so a future rule cannot quietly cite the front page again.\n\nTwo things I would take to any project, MCP or not.\n\n**Deterministic is not the same as correct.** The whole selling point of this\n\nengine is that it is reproducible: same input, same output, no model deciding\n\nthings differently on Tuesday. That is a real property and it is worth having.\n\nIt also gave me a rule that was reliably, repeatably, verifiably wrong. Determinism\n\nbuys you the ability to *argue* with the output. It does not buy you truth.\n\n**\"No result\" and \"you looked in the wrong place\" produce identical evidence.**\n\nThis is the one I keep thinking about. Absence of a thing at the address you\n\nchecked is not absence of the thing. The rename is a clean example, but the shape\n\nturns up everywhere: a config key that moved, an endpoint that was versioned, a\n\nfunction 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\n\nthe right place\" before \"so it does not exist\".\n\nI write more agent-driven code than I used to, and the failure mode has shifted\n\naccordingly. It is no longer mostly syntax. It is confident, plausible, internally\n\nconsistent 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.\n\nHere is the thing I got wrong about my own tool before I got the SDK wrong.\n\nThe source scan is regex-based. It reports *signals*, not proof. Point an agent at\n\nthe output and tell it to fix what it finds, and sooner or later it will hit\n\nsomething like this:\n\n```\napp.use(session({ secret: process.env.ADMIN_SESSION_SECRET }));\n\napp.get(\"/admin/whoami\", (req, res) => {\n  const sessionId = req.sessionID;   // ← MCP002 fires here\n  res.json({ sessionId, user: req.session.user });\n});\n\napp.post(\"/mcp\", async (req, res) => {\n  const transport = new StreamableHTTPServerTransport({\n    sessionIdGenerator: undefined,   // ← already stateless. Nothing to do.\n  });\n  // …\n});\n```\n\nThat is an Express session for an admin panel, on a server whose MCP transport is\n\nalready stateless. The rule is right that the string is there. Acting on it means\n\nrefactoring something that was never broken — and a change nobody needed is more expensive than the finding was worth.\n\nSo what actually ships is a procedure, not a scanner:\n\n`file:line`\n\nbefore touching anything,\nand say out loud which ones are noise. This step exists because of the code\nabove.The rule ids are what ties it together. The checker emits `MCP002`\n\n, and the\n\nremediation guide has a section keyed `MCP002`\n\nexplaining how to tell a real\n\nsession dependency from an Express one. Diagnosis and fix are not two documents\n\nthat drift.\n\nWorth being precise about, because \"works with every agent\" is the kind of\n\nconfident claim this whole post is about not making.\n\nThe `.skill`\n\nformat — a `SKILL.md`\n\nwith 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.\n\nThe contents are not Claude's. A `.skill`\n\nfile is a zip:\n\n```\nmcp-migration/\n├── SKILL.md                      the procedure, plain markdown\n├── references/remediation.md     per-rule guidance, keyed by id\n└── scripts/mcpcheck.mjs          the engine, one file, zero dependencies\n```\n\n`mcpcheck.mjs`\n\nis an esbuild bundle of the rule engine that needs nothing but\n\nNode. So for **Codex**, **Cursor**, or anything else that can run a shell command:\n\n```\nunzip mcp-migration.skill\nnode mcp-migration/scripts/mcpcheck.mjs --source ./my-server --json\nnode mcp-migration/scripts/mcpcheck.mjs https://example.com/mcp\n```\n\nExit codes are CI-friendly: `0`\n\nno critical findings, `1`\n\nat least one, `2`\n\ninconclusive. `references/remediation.md`\n\nreads fine on its own, or drop it next to\n\nan `AGENTS.md`\n\nso whatever agent you use has the same triage rules Claude gets.\n\nThe reason it is bundled rather than published as a dependency is exactly this: the\n\nskill lands on machines where no `npm install`\n\never ran, and an import would just\n\nfail there. The cost is a generated file committed to the repo, which can go stale\n\n— so CI rebuilds it on every push and fails if it differs.\n\nThe codemod is real and handles the mechanical half:\n\n```\nnpx @modelcontextprotocol/codemod@latest v1-to-v2 .\n```\n\nRun it on a clean tree. It rewrites import paths, symbol renames\n\n(`McpError`\n\n→ `ProtocolError`\n\n, `StreamableHTTPError`\n\n→ `SdkHttpError`\n\n),\n\n`setRequestHandler(Schema, …)`\n\n→ `setRequestHandler('method/string', …)`\n\n,\n\n`.tool()`\n\n→ `registerTool`\n\n, and `extra.*`\n\n→ `ctx.mcpReq.*`\n\n. Where it cannot decide\n\nsafely it leaves a marker instead of guessing:\n\n```\ngrep -rn '@mcp-codemod-error' .\n```\n\nNote that v2 requires **Zod 4**, so a project on Zod 3 has a second upgrade in\n\nfront of it.\n\nAnd then the part that is not mechanical, in the codemod's own words:\n\nThe codemod handles the v1→v2 SDK surface upgrade only. Adopting the 2026-07-28\n\nprotocol revision (`createMcpHandler`\n\n, multi-round-trip requests,\n\n`versionNegotiation`\n\n) is architectural and not codemod-automatable.\n\nThat is accurate and worth taking seriously. Removing session state is a design\n\ndecision — delete it, pass it as an explicit tool argument, or move it to a store\n\nboth instances can reach. No tool makes that call for you, and the deprecated\n\ncapabilities can wait twelve months while you do.\n\nAll of it is open source, MIT:\n\nThe README has a section called *A rule that was wrong*. It says roughly what this\n\npost says, in fewer words, and it stays there.", "url": "https://wpnews.pro/news/i-built-a-skill-and-checker-for-mcp-s-breaking-change-2026-08-26-then-the-was-it", "canonical_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_at": "2026-08-02 01:41:26+00:00", "updated_at": "2026-08-02 02:07:52.716754+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Model Context Protocol", "@modelcontextprotocol/sdk", "@modelcontextprotocol/server", "@modelcontextprotocol/client", "@modelcontextprotocol/core", "@modelcontextprotocol/node", "@modelcontextprotocol/codemod"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-skill-and-checker-for-mcp-s-breaking-change-2026-08-26-then-the-was-it", "markdown": "https://wpnews.pro/news/i-built-a-skill-and-checker-for-mcp-s-breaking-change-2026-08-26-then-the-was-it.md", "text": "https://wpnews.pro/news/i-built-a-skill-and-checker-for-mcp-s-breaking-change-2026-08-26-then-the-was-it.txt", "jsonld": "https://wpnews.pro/news/i-built-a-skill-and-checker-for-mcp-s-breaking-change-2026-08-26-then-the-was-it.jsonld"}}