Announcing CIMD support for MCP Client registration
Learn more
MCP Gateway
Nov 4, 2026

Versioning MCP Tools Without Breaking Running Agents

Nishant Choudhary
Tech Evangelist

A tool named search_issues had, for six months, a description that read "Search Jira issues by JQL." Someone on the server team changed it to "Search Jira issues by JQL. Prefer this over listing issues when the user mentions a project." The schema did not change. The version string did not change. No client noticed, because there was nothing in the protocol for them to notice.

Within a week, three agents that had been calling list_issues to build a daily digest started calling search_issues instead, with JQL the model improvised, returning fewer results, with no error anywhere. The behavior change shipped in a sentence.

TL;DR

  • An MCP tool has four things that version independently: the protocol revision, the server package version, the tool's input and output schema, and the tool's description. Only the first two have an agreed versioning scheme. The description, which is the one that most often changes agent behavior, has none.
  • Most agent harnesses call tools/list once per session and cache the result. Whether your change reaches a running agent depends on the client, not on you.
  • The 2026-07-28 protocol revision made MCP stateless over HTTP. The long-lived stream that notifications/tools/list_changed relied on is now a legacy-era mechanism; clients on the modern revision discover changes by re-listing. Design for re-listing, not for push.
  • Classify every change as additive, behavioral or breaking, and treat behavioral changes (description edits) with the same discipline as breaking ones. A new description is a new tool in everything but name.
  • The reliable pattern is to version the surface, not the tool: ship a new scoped server for the new behavior, point new runs at it, and retire the old one when no run depends on it. A Virtual MCP server, defined once per agent role with a static URL, is that pattern by construction.

Four axes, two of them governed

When someone says "we versioned the MCP server," they usually mean one of four different things.

Axis
What it governs
Who sets it
Versioning scheme
Protocol revision
Wire format, transport, lifecycle
The MCP specification
Dated revisions, with a feature lifecycle and a 12-month minimum deprecation window
Server package version
What the client installs or connects to
The server publisher
SemVer in server.json, recommended by the registry
Tool schema
inputSchema, output shape
The server author
None agreed; proposed registry guidance is that names stay stable across minor versions and schema breaks require a major
Tool description
How the model decides when and how to call it
The server author
None

The first two axes are solved problems with tooling. The 2026-07-28 revision introduced a formal feature lifecycle: anything deprecated in that revision cannot be removed before roughly August 2027. The specification's own versioning page now calls 2026-07-28 and later "modern," 2025-11-25 and earlier "legacy," and implementations supporting both "dual-era." Server packages carry a SemVer string that aggregators compare.

The third axis has a proposal and a convention. The fourth has nothing, and it is where the opening incident lives.

A tool description is an instruction to a model. Change the words and you change which tool the model picks, what arguments it fills in, and when it decides the tool is not applicable. That is a behavior change with the blast radius of a schema change and the visibility of a typo fix. No version bump is required by anything. No client is obliged to re-read it.

The list-once reality

Most agent harnesses call tools/list exactly once, at session start, and cache the result for the life of the session. The reasons are sound: tool definitions are large, serializing them into every model call is expensive in tokens, and re-listing on every turn adds a round trip per server. A harness that lists once and caches is doing the sensible thing.

The consequence is that a running agent does not see your update until it starts a new session. If your agents are long-running (a support agent that stays up for a shift, a scheduled job that holds one session across many tasks), "until a new session" can be hours.

The legacy answer was notifications/tools/list_changed. A server declaring the listChanged capability sent the notification when its tool set changed, and the client was expected to re-issue tools/list. The design assumed a persistent connection (stdio or a long-lived SSE stream) that the server could push on. Client support was uneven for over a year: Claude Code only honored the notification from version 2.1.0 in February 2026, Cursor's CLI still had an open request for it in 2026, and stateless HTTP wrappers that treated tools/list as cacheable on a TTL never saw it at all.

The 2026-07-28 revision changed the ground under this. The initialize handshake and protocol-level sessions are gone; protocol version and client info travel per request in _meta, and Mcp-Method and Mcp-Name are required headers on every call. Server-initiated requests are only permitted while the server is processing a client request. The model is request-response over plain HTTP, which is what made MCP play well with standard gateways and load balancers, and it means there is no idle stream for a server to push a list-changed notification into.

Design implication: on the modern revision, the client learns about tool changes by asking. Your update strategy has to work when the only signal you can give is "the next tools/list returns something different," and when you cannot know when that next listing will happen.

Classify the change before you ship it

Every change to a tool falls into one of three classes, and the class decides the rollout.

Additive. A new optional input field with a default; a new tool alongside existing ones; a new field in the output that callers can ignore. Safe to ship in place. Clients that cached the old list keep working with the old definition; clients that re-list get the new one. The one hazard is a new tool that overlaps an existing one: the model now has two candidates where it had one, and its selection may change. Add the tool, but write its description to disambiguate it from its neighbour explicitly.

Behavioral. The schema is unchanged; the description, parameter descriptions, or the implementation's behavior changed. This is the opening incident. It is also the class most teams ship as "just a wording fix." Treat it as breaking: it changes what the model does with the same inputs, and the only way to find out how is to run it.

Breaking. A renamed tool, a required field added, a field removed, a changed output shape, a changed error contract. Agents holding the old definition will construct calls the server rejects, or parse results that are no longer there.

A quick classification table your reviewers can apply:

Change
Class
Ship in place?
New optional input with default
Additive
Yes
New tool, non-overlapping
Additive
Yes
New tool overlapping an existing one
Behavioral
Only with disambiguating descriptions, and an eval run
Description or parameter description edit
Behavioral
No; version the surface
Implementation change with same contract
Behavioral
Only with an eval run
Rename, required field, removed field, output shape
Breaking
No; version the surface

Recommended Reading: Build Production-Ready Agent Workflows with Remote MCP Servers on structuring remote servers so that multiple teams can consume them.

Patterns that survive a cached tool list

Shadow the old name during deprecation. When you rename or restructure a tool, keep the old name serving for the deprecation window. The old name forwards to the new implementation and returns a result that tells the model, in the result content, that the tool is deprecated and what replaced it. The model carries that information into its next decision; the human sees it in the trace.

# server.py import re from fastmcp import FastMCP mcp = FastMCP("issues") # In-memory stand-in for the issue tracker so the server runs as written. ISSUES = [ {"key": "PAY-101", "project": "PAY", "status": "open", "title": "Refund webhook retries twice"}, {"key": "PAY-102", "project": "PAY", "status": "resolved", "title": "Currency rounding on invoices"}, {"key": "OPS-7", "project": "OPS", "status": "open", "title": "Alert noise from staging"}, ] def run_search(project: str, status: str | None, limit: int) -> list[dict]: rows = [i for i in ISSUES if i["project"] == project] if status: rows = [i for i in rows if i["status"] == status] return rows[:limit] def parse_jql_to_filter(jql: str) -> tuple[str, str | None]: project = re.search(r"project\s*=\s*\"?([A-Za-z0-9_-]+)\"?", jql, re.I) status = re.search(r"status\s*=\s*\"?([A-Za-z0-9_-]+)\"?", jql, re.I) if not project: raise ValueError("JQL must include a project clause") return project.group(1).upper(), status.group(1).lower() if status else None @mcp.tool( description=( "Search issues with a structured filter. Returns up to 'limit' issues " "matching 'project' and optional 'status'." ) ) def issues_search(project: str, status: str | None = None, limit: int = 50) -> dict: results = run_search(project=project, status=status, limit=limit) return {"issues": results, "count": len(results)} @mcp.tool( description=( "Deprecated. Use issues_search. Searches issues by raw JQL. " "Will be removed after 2027-08-01." ) ) def search_issues(jql: str, limit: int = 50) -> dict: project, status = parse_jql_to_filter(jql) results = run_search(project=project, status=status, limit=limit) return { "issues": results, "count": len(results), "deprecation": "search_issues is deprecated; call issues_search with project and status.", } if __name__ == "__main__": mcp.run()

The tradeoff is surface size: for the length of the window you serve two tools where you want one, and the model has to choose between them. The description on the old tool does that work. A deprecated tool whose description still reads like the preferred option will keep getting called.

Hash the surface in CI. The behavioral class is invisible to code review because the diff is prose. Make it visible: compute a hash of every tool's name, description and inputSchema at build time, store it in the repo, and fail the build when the hash changes without a corresponding entry in a changelog that names the tool and the class of change.

# tools_surface_check.py import asyncio import hashlib import json import sys from fastmcp import FastMCP from server import mcp # the FastMCP instance from your server module SURFACE_FILE = "tool_surface.json" def surface(app: FastMCP) -> dict[str, str]: tools = asyncio.run(app.get_tools()) out = {} for name, tool in tools.items(): payload = json.dumps( { "name": name, "description": tool.description, "inputSchema": tool.parameters, }, sort_keys=True, ) out[name] = hashlib.sha256(payload.encode("utf-8")).hexdigest() return out def main() -> None: current = surface(mcp) if "--write" in sys.argv: with open(SURFACE_FILE, "w", encoding="utf-8") as f: json.dump(current, f, indent=2, sort_keys=True) print("Wrote", SURFACE_FILE) return with open(SURFACE_FILE, encoding="utf-8") as f: recorded = json.load(f) changed = sorted(n for n in current if recorded.get(n) != current[n]) removed = sorted(set(recorded) - set(current)) if changed or removed: print("Tool surface changed without updating", SURFACE_FILE) for n in changed: print(" changed:", n) for n in removed: print(" removed:", n) print("Classify the change, update CHANGELOG.md, then run with --write.") sys.exit(1) print("Tool surface unchanged.") if __name__ == "__main__": main()

The hash does not tell you what changed. It tells you that a change happened in the one place your diff tooling does not look, and it forces the author to say which class it belongs to.

Reject calls to tools this session was never shown. On the server side, keep track of which definitions each listing returned, and refuse a tools/call for a tool that was added after that listing. The model's belief that a tool exists is not evidence that it was disclosed. This matters for additive changes mid-session: the model, having been told about a tool by a different server's description or by a user, calls a name that happens to resolve. Refusing with a result that says "re-list to use this tool" turns a silent surface mismatch into a visible one.

Pin server versions in clients, never tags. A client config pointing at acme/issues-mcp:latest receives every class of change the moment it is pushed. Pointing at acme/issues-mcp:2.1.0, or at a digest, receives none until someone changes the pin. For a platform where many teams consume one server, this is the difference between a coordinated rollout and a surprise.

Version the surface, not the tool

Every pattern above makes a change safer to ship in place. The pattern that makes breaking and behavioral changes boring is not to ship them in place at all.

A scoped server is a named set of tools for one agent role. When the role's tools need to change in a way that would alter behavior, you define a second server with the new tools, point new agent runs at it, and leave the old server serving old runs until none remain. There is no mid-session change for a cached list to miss, because no running session's server changed. The old surface is retired on your schedule, not the client's.

This is how Virtual MCP servers behave by construction. A server is defined once per agent role, listing exactly which tools from which connections it exposes, and it gets a static URL. Updating the tool mapping on an existing server is possible; the documented guidance is to do it when no agent run is using the server, because a run can lose a tool mid-conversation, and to create a new server for anything larger.

import os from dotenv import load_dotenv from scalekit import ScalekitClient from scalekit.actions.models.mcp_config import McpConfigConnectionToolMapping load_dotenv() scalekit_client = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit_client.actions # v1 keeps serving existing runs. v2 is a separate surface with the new tool set. # Connection names must match AgentKit > Connections exactly. v2 = actions.mcp.create_config( name="issue-digest-agent-v2", description="Builds the daily issue digest. Replaces issue-digest-agent.", connection_tool_mappings=[ McpConfigConnectionToolMapping( connection_name="github-connect", tools=["github_issues_list", "github_issue_get"], ), ], ) print(v2.config.id, v2.config.mcp_server_url)

New runs mint their session token against v2.config.id and connect to its URL. Runs already in flight on v1 finish on v1. When the last of them is gone, v1 is deleted, and deleting it stops its URL immediately, including for any session token still outstanding; that is the explicit retirement step rather than an accidental one.

The tradeoff is server count. For a platform with many agent roles, you will have more server definitions than you would with one big server that everyone shares, and someone has to own retiring the old ones. That is a cheaper problem than finding out which cached tool list each running agent is holding.

The scoped surface also shrinks what any single change can do. An agent that sees 5 tools rather than 40 has 5 descriptions that can drift, 5 schemas that can break, and a change log short enough to actually read. Prebuilt connector tools absorb one more axis entirely: when GitHub changes an API, the tool's contract to the agent stays the same, and the change is handled below the surface the model sees.

What to do on Monday

Add the surface hash check to the server's CI. It costs an afternoon and it is the only thing that will catch the next "wording fix."

Classify the last ten tool changes your team shipped using the table above. If any behavioral change went out in place without an eval run, that is the gap.

Pin every client to a version or digest, and find the ones on :latest.

For the agents that run long sessions, move their tool surface to a scoped server per role, and make "ship a change" mean "ship a new server." The protocol no longer promises to tell a running agent that its tools changed. Build as if it never did.

Recommended Reading: Tool Calling Authentication for AI Agents on why a tool's credential lifecycle is a separate axis from its definition.

The lifecycle of a Virtual MCP server, including when to update in place and when to create a new one, is documented at docs.scalekit.com/agentkit/mcp/configure-mcp-server.

FAQs

What are the four things that version independently in an MCP tool?

The protocol revision, the server package version, the tool's input and output schema, and the tool's description. Only the first two have an agreed versioning scheme; the description, which most often changes agent behavior, has none.

Why does a running agent not see my tool update?

Most harnesses list tools once per session and cache the result, because definitions are expensive in tokens. Your change reaches that agent when it starts a new session, which for long-running agents can be hours later.

What happened to the tools list changed notification?

It relied on a persistent stream the server could push on. The 2026-07-28 revision removed sessions and server-initiated requests, so on the modern revision clients discover changes only by re-listing.

How should I classify a tool change?

Additive changes, such as a new optional field or a non-overlapping tool, ship in place. Behavioral changes, including description edits, and breaking changes, such as renames or required fields, should get a new surface rather than an in-place edit.

How can CI catch a description-only change?

Hash every tool's name, description and schema at build time, store the hashes in the repo, and fail the build when a hash changes without a changelog entry naming the tool and the class of change.

What does versioning the surface instead of the tool mean?

Ship a second scoped server with the new tools, point new runs at it, and retire the old one when no run depends on it. No running session's server changes, so there is no mid-session update for a cached list to miss.

No items found.
On this page
Share this article
Scalekit
Find us on ProductHunt

Acquire enterprise customers with
‍zero upfront cost.

Every feature unlocked. No hidden fees.