We spent three weeks earlier this year moving a Python dict full of tool JSON into an MCP server. The agent code lost sixty lines. The server turned into its own small repo with its own CI and its own owner on the policy team. Nothing about what the agent could do changed. What changed was who owned the contract.
That's the honest version of what MCP does. The specification has a lot of surface — resources, prompts, transports, auth — but the piece that keeps showing up in our work is simpler than any of that. Tool definitions moved out of the agent process and into something a different team can own. Everything else flows from that. You can swap model vendors without rewriting every tool. A second agent can reuse the first one's tools. A compliance review can run on its own clock.
What this looked like before
Before MCP, every tool we wrote lived inside the agent. A block of JSON next to the Claude Messages call, a dispatch function in the same file, a couple of imports that pulled in whatever the tool actually did. If you asked what tools the agent could use, you grepped.
The code looked something like this. There is nothing wrong with it for a single agent that will never talk to a second vendor:
# Tools inlined into the agent — coupled to the Claude Messages API.
# Change vendors and every one of these gets rewritten.
TOOLS = [
{
"name": "lookup_policy",
"description": "Return the policy document active at a date.",
"input_schema": {
"type": "object",
"properties": {
"policy_id": {"type": "string"},
"date": {"type": "string", "format": "date"},
},
"required": ["policy_id", "date"],
},
},
# ... 11 more tools, same shape
]
async def resolve_tool(name: str, args: dict) -> str:
if name == "lookup_policy":
return await policy_store.at(args["policy_id"], args["date"])
# ... 11 more branches
def run_claim(claim):
return client.messages.create(
model="claude-sonnet-5-5",
tools=TOOLS,
messages=[{"role": "user", "content": claim}],
)The problems don't show up until you need something to change. The vendor's tool-input shape moves — ours has moved three times across the vendors we touch — and you edit every tool in every repo. The policy team needs to tweak the tool's input contract and can't, because the contract lives in the agent's codebase. A second agent appears, needing the same tool, and the only option is a copy-paste. Now the drift timer is running.
The same tool, behind an MCP server
Different repo. Different owner. Different deploy cadence. The agent runtime connects over stdio or streamable HTTP, lists what's available, and calls it:
# server.py — an MCP server exposing the tool once, consumable
# from any MCP-aware client (Claude Agent SDK, OpenAI Agents SDK,
# LangGraph nodes, Claude Desktop, Claude Code, etc.).
from mcp.server import Server
from mcp.types import Tool, TextContent
server = Server("policy-service")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="lookup_policy",
description="Return the policy document active at a date.",
inputSchema={
"type": "object",
"properties": {
"policy_id": {"type": "string"},
"date": {"type": "string", "format": "date"},
},
"required": ["policy_id", "date"],
},
),
]
@server.call_tool()
async def call_tool(name: str, args: dict) -> list[TextContent]:
if name == "lookup_policy":
doc = await policy_store.at(args["policy_id"], args["date"])
return [TextContent(type="text", text=doc.serialize())]
raise ValueError(f"Unknown tool: {name}")The agent code now has no tool array. It points at the server, lists what's there, and hands the result to whichever model SDK you're running that week:
# Agent — no longer coupled to a vendor's tool shape.
async with mcp_client.stdio_client("policy-service") as session:
tools = await session.list_tools()
# tools.list goes into the model SDK's `tools=` parameter.
# When the server team adds a tool, the agent gets it on next startup.What we noticed after the migration was mundane in the best way. Fewer merge conflicts. The policy team shipped a tool improvement on their own branch without the agent team being in the loop. When a second agent showed up — different vendor, chosen for latency reasons — it consumed the same server with roughly zero wiring.
When MCP is worth the ceremony
Three things to check before you go to the trouble. If none of them apply, inline the tool and move on.
- The tool will be called by more than one agent. One is fine inline. Two is where MCP starts paying.
- The team owning the tool is different from the team owning the agent. A protocol is a cheap way to formalize an interface across a team boundary.
- You can see yourself swapping LLM vendors in the next year or two. This is more common than teams expect. If you've already been through one vendor swap you know; if you haven't, assume you will.
When it's theater
Three ways we keep seeing this done wrong on other people's code.
One: an MCP server wrapping exactly one internal REST endpoint, called by exactly one agent. The server has added a process, a protocol layer, and a deployment story — to shape-shift the same call. If the second consumer is never coming, delete the server and inline the tool.
Two: MCP as the auth layer. The spec underspecifies auth on purpose. It's the client's problem, and OAuth 2.1 is what most clients end up on. If the actual work is auth, do that directly and call it auth.
Three: "MCP migration" that's really a thin-HTTP-client rewrite with better retries. Those exist. Ship a library.
What we got wrong the first time
Transport matters more than the spec hints at. Stdio is comfortable for local dev and single-process deployments. The moment the server lives in its own container on its own clock, HTTP (streamable) is the one you want. Everything you'd put on any other internal service — auth, observability, retries, rate limits — belongs here too. MCP did not invent any of that.
Long-running tool calls are the shape that keeps breaking. There is no first-class notion in the spec of a tool that takes four minutes. We split them. A `start_job` tool returns a job id. A `poll_job` tool returns the result or an in-progress sentinel. The agent plan expects the two-step. One long blocking call through a single MCP method works in dev and loses the first time a connection drops.
Resources and tools get conflated. The spec separates them — resources are read-only content a client can subscribe to; tools are action-shaped. Most of the servers we've reviewed shove everything into tools because that shape is familiar. Keeping the separation matters when you add caching. Resources cache naturally. Tools don't.
The rule we use now
Before an engagement writes a new tool surface, we ask one question. Is this going to be consumed by one agent, one vendor, forever? If yes, skip MCP. Inline the tools and stop pretending there's a second use case coming. If anything else is true — a second agent is plausible, a second vendor is on the table, a different team will own the tool — design the surface as MCP on day one. The cost is not zero but it's honest, and we've never regretted putting a boundary there when the second consumer showed up.
Four LangGraph topology patterns that survive production.