Chapter 28: Migration & Change Management
Almost no team adopts MCP from a clean slate. They already have direct API integrations, internal automation scripts, product-specific assistants, browser extensions, or fragile workflow glue built around whichever client happened to arrive first.
That means adoption is usually a migration problem before it is a design problem. MCP supports incremental migration well: capability negotiation, dynamic tool discovery and additive schema evolution all favor gradual change. Teams squander that advantage by moving too much at once, or by underestimating how many moving parts a “simple tool rename” actually touches.
Start With an Inventory, Not a Rewrite
Before you design the migration, inventory what already exists:
- Current workflows and who depends on them.
- Which actions are read-only versus side-effecting.
- How identity and approval work today.
- What downstream systems are already in scope.
- Which integrations are brittle enough that replacing them would create immediate value.
This inventory usually shows that the current system is a collection of workflows with different owners, risk levels and operational expectations. Treated as one migration stream, the riskiest workflow sets the pace for all of them.
A team wrapping internal GitHub and Jira APIs might find the GitHub integration stateless, read-heavy and used by four clients, and the Jira integration stateful, write-heavy and owned by one team with its own approval flow. Those are two migrations with different risks, rollout orders and success criteria, even if they end up on the same server. Build the inventory as a table:
| Integration | Read/Write | Current auth | Consumers | Failure impact | Migration priority |
|---|---|---|---|---|---|
| GitHub PR lookup | Read | PAT | 4 clients | Low (cached) | High — broad value |
| Jira ticket create | Write | OAuth + approval | 1 team | Medium — blocks workflows | Low — narrow, complex |
| Internal metrics | Read | API key | 2 dashboards | Low | Medium |
This table becomes your sequencing guide. Migrate high-value, low-risk integrations first. Leave complex write paths for later, when the team has operational confidence.
Migrate in Layers, Not Leaps
The cleanest path usually follows this sequence:
- Pick one narrow workflow that clearly benefits from MCP.
- Build a small server surface for that workflow.
- Run it beside the legacy path — both paths active simultaneously.
- Observe failure rates, latency, cost, and user behavior on the new path.
- Expand only after the interface proves itself under real load.
- Retire legacy paths deliberately, with explicit cutoff dates, rather than abandoning them half-owned.
Big-bang migration bundles new tool design, new auth, new client UX and new failure modes into one change. When something breaks, you cannot tell which of them did it.
The Parallel-Run Pattern
The most reliable migration approach keeps both old and new paths active during transition. For the GitHub integration above, the MCP tool and the legacy REST handler both call one shared domain function:
from datetime import datetime, timezone
from mcp.server.mcpserver import MCPServer
import domain # the shared layer: the legacy /api/v1/prs handler imports this too
mcp = MCPServer("engineering")
@mcp.tool()
def get_open_prs(repo: str, limit: int = 20) -> dict:
"""List open pull requests for a repository: number, title, author and age in days."""
prs = domain.list_open_prs(repo, limit=limit)
now = datetime.now(timezone.utc)
return {
"repo": repo,
"count": len(prs),
"pull_requests": [
{
"number": pr.number,
"title": pr.title,
"author": pr.author,
"age_days": (now - pr.created_at).days, # created_at is timezone-aware UTC
}
for pr in prs
],
}
The legacy endpoint stays alive. Teams adopt the MCP path on their own timeline. You monitor both paths and retire the legacy endpoint only when its traffic has fully migrated and a grace period has passed.
The key discipline: the legacy path should not depend on the MCP server, and the MCP server should not depend on the legacy endpoint. Both call domain directly. If one goes down, the other keeps working. The tool’s job is the part the REST handler never had: a flat, bounded result shaped for a model.
Migration From Direct Function Calling
Teams using vendor-specific function calling (OpenAI’s function calling API, Anthropic’s tool_use, or Google’s function declarations) already have tool definitions, bound to one vendor’s client. Extract them into a shared schema layer, wrap the same logic in an MCP server, and keep the vendor path alive for clients that do not speak MCP yet. The extraction is the easy part. Vendor function calling carries implicit behavior that MCP does not replicate: tool choice constraints, parallel tool calling settings, strict mode schemas, response format requirements. Document these gaps before migrating, so the team is not surprised when the MCP path behaves differently.
Versioning Your Tool Surface
Getting onto MCP is a one-time migration. Changing what is on MCP never stops, and the rest of this chapter follows the tickets server as its surface changes.
MCP does not define a versioning scheme for tools. There is no toolVersion field in the specification, so you manage it yourself.
What Constitutes a Breaking Change
Not every change breaks clients. Overclassifying changes as breaking leads to version sprawl; underclassifying them leads to silent failures.
Breaking changes (require explicit migration):
- Renaming a tool. Models and client configurations reference tools by name. Changing
get_prstolist_pull_requestsbreaks every caller. - Removing a required parameter or adding a new required parameter.
- Changing the semantic meaning of a parameter. If
limitpreviously meant “maximum results” and now means “page size,” that is breaking even though the schema looks the same. - Removing a tool entirely.
- Changing response structure when clients or downstream logic parse specific fields.
- Tightening auth or approval requirements without a transition period.
Non-breaking changes (safe to deploy without migration):
- Adding a new optional parameter with a sensible default.
- Adding a new tool (the catalog grows, but no existing call breaks).
- Adding fields to a response (assuming consumers ignore unknown fields).
- Improving error messages.
- Internal performance or reliability improvements.
Gray area (test before assuming safety):
- Changing a tool description substantially. Models use descriptions to decide when and how to call tools. A major description change can shift model behavior in ways that look like a bug to users.
- Changing default values for optional parameters.
- Reordering response fields (should not matter, but some brittle parsers care).
Additive-Only Evolution
The safest versioning strategy is additive-only: introduce new tools alongside old ones, let clients migrate, then deprecate the old tools after a transition period. Here search_tickets gains multi-status search. Changing status: str to statuses: list[str] in place would break every existing call, so it ships as a second tool:
@mcp.tool()
async def search_tickets(status: Literal["open", "closed", "pending"] = "open", page: str | None = None) -> dict:
"""Deprecated: use search_tickets_v2, which accepts several statuses. Removed after 2027-03-31."""
return await _search(statuses=[status], page=page)
@mcp.tool()
async def search_tickets_v2(
statuses: list[Literal["open", "closed", "pending"]],
priority: Literal["low", "medium", "high", "critical"] | None = None,
page: str | None = None,
) -> dict:
"""Search support tickets in your workspace by one or more statuses and an optional priority."""
return await _search(statuses=statuses, priority=priority, page=page)
The deprecation notice goes in the description, with a calendar date, because the model is the caller that has to see it. It leads the description so the model reads it before anything that would make the old tool look like a fit. The new tool’s description does not mention the old one; it stands on its own.
listChanged Is a Hint, Not a Delivery Guarantee
A server that declares listChanged: true can tell clients its catalog changed. Under 2026-07-28 that notification travels only on a subscriptions/listen stream the client opened and opted into with toolsListChanged. Clients that never open the stream never hear it. Clients that do may still keep the old catalog in the model’s context for the rest of the conversation.
So the catalog also carries cache hints. Every tools/list result includes ttlMs (how long the client may treat it as fresh) and cacheScope. Set ttlMs to how stale a catalog you can tolerate during a rollout, typically a few minutes, and treat list_changed as the fast path on top. Before relying on either, test your target clients for two behaviors: whether they re-list when the TTL expires, and whether a re-listed catalog reaches the model mid-conversation or only in the next one.
Rollout Patterns
Rollout by Trust Level
Traffic percentage is a common rollout axis for traditional services, but for MCP servers trust level is often more useful. The users who should see a new tool first are the users best equipped to report problems and tolerate failures, not a random sample.
A sensible rollout order:
- Internal operators and the team that built the server.
- Trusted pilot teams with established feedback channels.
- Read-oriented general users (where failures are low-consequence).
- Broader audiences for lower-risk tools.
- Write or externally acting tools last.
That order surfaces interface and policy problems before the highest-risk actions depend on them.
Hide What Callers Have Never Seen; Error on What They Have Used
One rule settles when a tool should vanish from the catalog and when it should stay callable and fail loudly:
- Rollout: hide. A tool this caller has never seen is simply absent from their
tools/list, and a call to it gets the same answer as a tool that never existed. A “tool disabled” error wastes a round trip and teaches the model the tool exists. - Removal: explain. A tool callers have already used returns an explicit error naming its replacement, because models, saved prompts and client configurations still refer to it.
Feature flags implement the first half. The server filters its own catalog per caller and refuses calls to hidden tools the same way:
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.caching import CacheHint
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
FLAGS = { # in production, read these from your flag service
"bulk_close_tickets": {"enabled": False, "allowlist": {"internal-ops"}},
"export_tickets_v2": {"enabled": True, "allowlist": set()},
}
def is_tool_enabled(tool: str, tenant_id: str | None) -> bool:
flag = FLAGS.get(tool, {"enabled": True, "allowlist": set()})
return flag["enabled"] or tenant_id in flag["allowlist"]
def caller_tenant() -> str | None:
token = get_access_token()
return (token.claims or {}).get("tenant_id") if token else None
class FlaggedServer(MCPServer):
async def list_tools(self): # serves tools/list
tenant = caller_tenant()
return [t for t in await super().list_tools() if is_tool_enabled(t.name, tenant)]
async def call_tool(self, name, arguments, context=None): # serves tools/call
if not is_tool_enabled(name, caller_tenant()):
raise ToolError(f"Unknown tool: {name}") # same answer as a tool that never existed
return await super().call_tool(name, arguments, context)
# The catalog now differs per caller, so no shared cache may reuse it.
mcp = FlaggedServer("tickets", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="private")})
cacheScope: "private" is not optional here. With "public", a shared gateway may serve the internal-ops catalog, bulk_close_tickets included, to every other tenant (Chapter 25). The gateway firewall in Chapter 26 reads a platform-owned allowlist; these flags are the server team’s own rollout switches. Both filter the same catalog.
Environment-Based Staging
Staging and production are two deployments of the same server at two URLs, each reading its tier from its own environment:
# staging deployment: https://mcp-staging.example.com/mcp
TOOL_TIER=experimental
# production deployment: https://mcp.example.com/mcp
TOOL_TIER=stable
The server reads TOOL_TIER at startup and registers experimental tools only when it is experimental. Internal users point their clients at the staging URL; everyone else uses production. An env block in a client’s config does not do this: it sets variables for a server process the client launches locally, and a remote server never sees them.
Canary Deployments
For remote MCP servers behind a load balancer, canary deployments work as for any HTTP service, with one MCP-specific wrinkle: a renamed tool might not cause errors. The model just stops using it and falls back to a different approach. Monitor tool invocation counts per tool name, not just error rates. A sudden drop in calls to a specific tool after a deploy is a signal that something changed, even if no errors fired.
Rollback Is Both Runtime and Contract Work
Deployment rollback is necessary but not sufficient. Interface rollback is a separate problem.
Ask in advance:
- Can the previous server version still understand requests that were shaped by the new version’s schema?
- Do clients cache tool schemas or descriptions that will be stale after rollback?
- What happens to in-flight Tasks or queued work that was started by tools in the new version?
- Does every tool that callers have used, including ones the rollback removes, fail with an explicit error rather than vanishing (the rule above)?
Graceful Tool Removal
Before the sunset date, the deprecated tool keeps working and its description says so. After the sunset date, it leaves the catalog, and a tombstone answers anyone still calling it:
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
REMOVED = { # tools callers have used: after removal they get an explicit error, not "unknown tool"
"search_tickets": "search_tickets was removed on 2027-03-31. Call search_tickets_v2 with the same status.",
}
class TicketsServer(MCPServer):
async def call_tool(self, name, arguments, context=None):
if name in REMOVED and name not in {t.name for t in await self.list_tools()}:
raise ToolError(REMOVED[name])
return await super().call_tool(name, arguments, context)
The tombstone costs one dictionary entry. Keep it until the per-tool invocation count for the removed name has been zero for a full release cycle.
Rollback Checklist
Before any deployment that changes the tool surface:
- Verify the previous version can still be deployed cleanly (images exist, configs are compatible).
- Confirm that rolling back the server does not orphan in-flight Tasks.
- Test that clients reconnecting after rollback get the correct (old) tool catalog.
- Document what manual steps are needed beyond reverting the deploy (cache invalidation, client restarts, configuration rollback).
- Set a time limit: if the new version is not promoted within the canary window, roll back automatically.
Communicate Changes With a Changelog
Developers, end users, security reviewers, on-call and platform owners each need something different from a change. One changelog with a section per concern covers all of them, as long as every deprecation carries a date:
## v2.4.0 — 2027-01-15
### New tools
- `search_tickets_v2`: several statuses per search, optional priority filter.
Replaces `search_tickets` (sunset: 2027-03-31).
- `export_tickets_v2`: filtered export with format selection (CSV, JSON, PDF).
Replaces `export_tickets` (sunset: 2027-04-15).
### Deprecated
- `search_tickets`, `export_tickets`: descriptions carry the sunset date.
After the sunset date, calls return an error naming the replacement.
### Breaking (staging only)
- `bulk_close_tickets`: now requires `confirm: true`.
Behind a feature flag in staging. Production rollout: 2027-02-01.
### Operator notes
- New alert: `export_tickets_v2_timeout` fires if PDF generation exceeds 30s.
- `tools/list` ttlMs lowered to 300000 for the rollout window.
- Rollback: reverting to v2.3.x removes `search_tickets_v2` and `export_tickets_v2`.
Clients using them see tool-not-found errors until their catalog refreshes.
Measuring Migration Success
If all you can show is that “we use MCP now,” that is activity, not progress. Define success before the migration starts, and track three signals throughout:
- Legacy traffic decline: traffic on the old path falls on the expected timeline.
- Per-tool invocation counts: new tools pick up the calls old tools lose, with no unexplained drops.
- Catalog health: deprecated tools are removed on their dates instead of accumulating.
Failure Modes
Migration stall. The parallel run becomes permanent. Both paths stay active indefinitely because no one owns the cutoff decision. Legacy traffic never drops to zero because one team never migrated and no one enforced the deadline. Prevention: assign a migration owner, set a hard sunset date at the start, and treat the date as a production commitment.
Description drift during migration. The old tool’s description says one thing, the new tool’s description says something slightly different, and models choose between them inconsistently. Users see different behavior depending on which tool the model picks. Prevention: make the old tool’s description explicitly direct traffic to the new one.
Schema-silent breaking changes. A parameter’s type stays the same but its meaning changes. limit used to mean “total results” and now means “per-page size.” The schema validates fine. The model sends the same value. The results are wrong. Prevention: treat semantic changes as breaking even when the schema is unchanged. Add a new parameter instead.
Rollback without catalog refresh. The server rolls back, but clients keep the newer catalog they cached, and the model calls a tool that no longer exists. Emitting list_changed from the rolled-back process does not help: redeploying closed every subscriptions/listen stream, so nobody is listening yet. Prevention: short ttlMs on tools/list during rollout windows, and tombstones for any tool the rollback removes, so stale calls get an error that names what to do.
Feature flag leak. An experimental tool is enabled for a pilot group, but the flag check has a bug and the tool appears in the catalog for all users. A write-oriented experimental tool goes live to the full user base without review. Prevention: default flags to disabled, test the disabled path, and audit the catalog from a non-privileged caller’s perspective before promoting.
Deprecation without sunset. Tools are marked deprecated but never actually removed. The catalog grows indefinitely. Models waste context window budget on tools that the team wishes no one would use. Prevention: tie every deprecation notice to a calendar date, and enforce it. A deprecation without a date is just a comment.
Multi-version confusion. The server supports both search_tickets and search_tickets_v2, but their descriptions do not clearly explain which to prefer. The model alternates between them unpredictably. Prevention: the old tool’s description should say “use search_tickets_v2” in its first sentence. The new tool’s description should not mention the old one.
Implementation Checklist
- Build an integration inventory with risk levels, consumers and auth mechanisms, and sequence migrations by value and risk.
- Run each migrated workflow in parallel with its legacy path, both calling one shared domain layer, with a named owner for the cutoff.
- Classify every tool-surface change as breaking, non-breaking or gray-area, and ship breaking changes as new tools.
- Put deprecation notices first in the tool description, with a calendar sunset date, and keep a tombstone after removal.
- Hide flagged-off tools from
tools/listand answer calls to them as unknown; returncacheScope: "private"for per-caller catalogs. - Set
ttlMsontools/listto the staleness you can tolerate during rollouts. - Monitor invocation counts per tool name after every deploy, not just error rates.
- Publish a dated changelog that covers developers, operators and security reviewers.
What Comes Next
Every change in this chapter was one you schedule: your tools, your deprecations, your sunset dates. The protocol, the SDKs and the clients change on their own schedules, and some of what they ship is solid enough to build on while some is not. Chapter 29 sorts the current ecosystem into what you can bet on, what needs a fallback, and what not to depend on yet.