Recipe-Inhalt ist auf Englisch. Englisches Original lesen →
← Alle Recipes
Phase 9 · Distribution·10 steps

Breaking changes, evolving a published server without breaking users

A tool definition has no version field and no deprecated flag. So the package is your only version signal, and most users are pinned to nothing. The alias window, the semver rules, and the snapshot test that catches an accidental rename before publish.

10 steps0%
Du liest ohne Account. Mit Login speichern wir Step-Fortschritt + Notes.

Breaking changes, evolving a published server without breaking users

You shipped. Someone ran claude mcp add foo -- npx -y your-package and forgot about it. That command has no version in it, so every time their client starts, npm fetches your latest release and runs it. Your next publish lands on their machine without them asking for it.

That is the whole problem in one sentence. Recipes 9.1 through 9.5 got your server out the door. This one is about the second release, and every release after that.

Schritt 1: The unit of versioning is the package, not the tool

Check the shape of a tool definition in the spec. It carries name (the unique identifier), title, description, inputSchema, outputSchema and annotations. That is the list. There is no version field, and there is no deprecated flag either.

This has a consequence people miss: you cannot ship search_v2 alongside search and call that versioning. Those are simply two different tools with two different names, and the client has no idea they are related.

The only version signal that reaches a user is version in your package.json. Everything in this recipe hangs off that.

Schritt 2: Know which column your change is in

Before you touch anything, put the change in one of two columns.

Breaking, needs a major:

  • Renaming a tool
  • Removing a tool
  • Removing a parameter, or renaming one
  • Making an optional parameter required
  • Narrowing a type or removing a value from an enum
  • Changing the shape of what you return in structuredContent

Safe, minor or patch:

  • Adding a new tool
  • Adding an optional parameter
  • Widening a type, or adding a value to an enum
  • Rewriting a description to be clearer
  • Fixing a bug where the tool already did the documented thing badly

One case sits between the columns: adding an outputSchema to a tool that never had one. It looks additive, but a client that validates results against it will now reject responses it used to accept. Treat it as a minor at best, and mention it in the changelog.

Schritt 3: Renaming a tool is the expensive one

Removing a parameter breaks code. Renaming a tool breaks text the user wrote.

Tool names end up in places you cannot reach: a CLAUDE.md that says "always use fetch_report before answering", a skill file that names your tool in its trigger, a hook config that matches on it, a saved prompt in someone's team wiki. None of that is code, none of it is versioned against your package, and none of it throws an error you will ever see. It just quietly stops working, and the user concludes your server is flaky.

So the bar for a rename is higher than the bar for any other change. If the name is merely inelegant, keep it.

Schritt 4: If you must rename, open an alias window

Register both names. Point them at the same handler. Do not fork the logic.

const TOOLS = [
  {
    name: "fetch_report",
    description:
      "DEPRECATED, renamed to get_report. Still works, will be removed in v3.0.0. " +
      "Fetch a report by id.",
    inputSchema: reportInputSchema
  },
  {
    name: "get_report",
    description: "Fetch a report by id.",
    inputSchema: reportInputSchema
  }
];

// one handler, both names
async function handleReport(args) { /* ... */ }

The old name stays for at least one major cycle. Long enough that a user who checks in once a quarter still gets a working server plus a warning, rather than a broken one.

Schritt 5: Deprecation lives in the description, because nothing else exists

There is no standard field to mark a tool as deprecated, so the description string is your only channel. That means the model reads it too, which is exactly what you want, it will prefer the tool whose description does not start with a warning.

Write it in a fixed order so it stays scannable:

DEPRECATED, use <new_name> instead. Removed in <version>. <original description>

Put the same three facts in your README next to the tools table. A user comparing the two should never have to guess which one is current.

Schritt 6: Tell live sessions that the list moved

A client that connected before your change is holding a cached tool list. If your server declares the listChanged capability, you can send notifications/tools/list_changed and the client will re-fetch.

{ "jsonrpc": "2.0", "method": "notifications/tools/list_changed" }

This matters for a hosted server, where a session can outlive a deploy. For a local stdio server it matters less, because the process restarts with the client anyway. Declare the capability regardless, it costs nothing and it is the only in-band way to say "look again".

Schritt 7: Make semver mean what it says

Anything from the breaking column is a major. No exceptions, and specifically not "it is only a small rename, I will slip it into a patch". A patch release is the one thing users assume they can take blindly, so it is the worst possible carrier for a breaking change.

npm version patch   # bugfix, tool surface unchanged
npm version minor   # new tool, new optional param
npm version major   # anything from the breaking column

If you are not ready to commit to that, publish 0.x and say so in the README. Under 0.x the expectation is already "this moves", and users pin accordingly. That is an honest position. Shipping 2.4.1 with a renamed tool is not.

Schritt 8: Give users something to pin to

Most install instructions in the wild, including the one in recipe 9.1, use npx -y your-package with no version. That is the right default for a first install, it is the shortest path to a working setup. But your README should show the pinned form directly underneath, so anyone running your server in CI or in a team setup has an obvious way to stop surprises.

# always latest
claude mcp add foo -s user -- npx -y your-package

# pinned to a major, no breaking changes without an explicit bump
claude mcp add foo -s user -- npx -y your-package@1

Two lines in the README, and the people who need stability will find it.

Schritt 9: Write the changelog entry for the person who has to act

"Refactored tool handling" tells a user nothing. They need to know whether to touch anything, and what.

## 2.0.0

### Breaking
- `fetch_report` renamed to `get_report`. The old name still works and will be
  removed in 3.0.0. Update any CLAUDE.md, skill or hook that names it.
- `list_items` no longer accepts `limit: 0` for "all". Use `limit: 1000`.

### Added
- `get_report` accepts an optional `format` ("json" | "markdown"), defaults to "json".

Every breaking line answers: what changed, does the old way still work, when does it stop, and what do I do now.

Schritt 10: Verify, snapshot your tool surface

The reliable way to catch an accidental breaking change is to make the tool surface a test fixture. Serialise the definitions, commit the snapshot, and let the diff argue with you at review time instead of a user arguing with you after release.

// tools.snapshot.test.js
import { describe, it, expect } from "vitest";
import { TOOLS } from "../src/tools.js";

describe("tool surface", () => {
  it("matches the committed snapshot", () => {
    const surface = TOOLS
      .map(t => ({
        name: t.name,
        required: t.inputSchema.required ?? [],
        params: Object.keys(t.inputSchema.properties ?? {}).sort()
      }))
      .sort((a, b) => a.name.localeCompare(b.name));

    expect(surface).toMatchSnapshot();
  });
});

Now a rename, a removed parameter or a newly required field shows up as a red test. When the change is intended you update the snapshot in the same commit, which means the diff is sitting right there in the pull request where someone can ask whether the major bump is in too.

Run it in the same workflow that publishes, so a failing snapshot blocks the release rather than annotating it.

Common traps

  • Shipping a rename in a patch. The single most damaging thing you can do to a npx -y user base, because patch is the release people take without reading.
  • Silently dropping the alias one release early. You planned a window, then cleaned up ahead of schedule. Now the deprecation notice you published is wrong, which is worse than never having written it.
  • Bumping major for a description fix. Over-signalling trains users to ignore your majors, and then the real one slips past.
  • Treating outputSchema as free. Adding one to an existing tool changes what a validating client accepts.
  • Forgetting the non-code surface. After any rename, grep your own docs, README, recipes and example configs for the old name. If you cannot keep your own references straight, users have no chance.

What good looks like

A user who installed six months ago and never pinned anything still has a working server. If they open their client today, a tool they wrote into their CLAUDE.md either still works, or works with a deprecation notice that names the replacement and the removal version. Nothing they wrote has silently become a no-op.

Meanwhile you are still free to change things. The alias window, the honest major bump and the snapshot test are what buy you that freedom, they turn "I cannot touch this, people depend on it" into a normal release.

Source

Writing a README that actuallyStripe Checkout, subscription