If you are wiring tools into an LLM agent, you will hit this fork quickly: do you define the tools directly against the model's API, or do you stand up an MCP server? The framing "API versus MCP" is a little misleading, because MCP runs on top of APIs rather than replacing them. The real question is when it pays to put a standard adapter in front of your tools instead of hand-wiring each one.
This is a practical walk through both paths. We will build the same capability twice, once as a direct tool call and once as an MCP server, and end with a rule of thumb for choosing.
The two paths
Direct tool calling. Tool calling follows the same shape across the major model APIs: you describe each tool as a JSON schema, send it with your request, and the model replies with a structured request to call one. The field names and response types differ by provider; the examples below are the Anthropic API. Your code runs the actual work, usually by calling a normal HTTP API, and hands the result back. You own both ends: the tool definitions live in your application, and so does the code that executes them.
MCP (the Model Context Protocol). MCP is an open, JSON-RPC standard for how an LLM application discovers and calls capabilities from a separate server. A server exposes three kinds of things: tools (actions the model can call), resources (read-only data the app can pull in), and prompts (reusable templates). Any MCP-compatible client can connect to any MCP server and use what it exposes, without custom integration code. The current spec defines two transports: stdio, for a server running as a local subprocess, and Streamable HTTP, for a server reached over the network.
Why raw APIs strain as you add agents
APIs are not bad. Everything below still ends in an API call. The strain shows up in the wiring around them.
A REST endpoint is built for a programmer who already read the docs. It has no standard way to advertise "here are my callable actions and their exact input shapes" to a model. So for every tool, in every application, someone writes the glue: the schema, the auth handling, the call, and the reshaping of a verbose response into something the model can use.
That glue does not compose. If you have N applications that each need M tools, you are on the hook for roughly N times M bespoke integrations, and every new agent starts from zero. The arithmetic is the shape of the argument rather than a formula: per-server auth, deployment, and operations do not collapse the same way. This is the problem MCP is built to solve. Move the tool behind an MCP server once, and every MCP client speaks to it the same way. The N times M glue collapses toward N plus M: each client learns MCP once, each tool is exposed once.
To sum up the argument so far, the case for MCP rests on standardization and reuse rather than on any new capability. Therefore, if you have nothing to standardize or reuse yet, an MCP server is overhead you are carrying for a payoff that has not arrived.
Setup
Everything below runs on Python 3.10+ in one virtualenv. Install them into one virtualenv:
python -m venv .venv && source .venv/bin/activate
pip install anthropic fastmcp fastapi uvicorn httpx
export ANTHROPIC_API_KEY=sk-ant-...Pin what you install, and note the versions you end up with. The MCP surface is still moving between releases on both sides: the connector below runs behind the mcp-client-2025-11-20 beta, so the Anthropic SDK has to be recent enough to carry it, and FastMCP changed what its tool decorator accepts partway through 2.x. A tutorial that names floors it has not retested ages into an import error or a signature mismatch, which is why this one names none.
Path one: a direct tool call
Say you want an agent to look up order status. Start with a plain service. Nothing here is AI-specific.
# orders_api.py — an ordinary FastAPI service.
from fastapi import FastAPI, HTTPException
app = FastAPI()
ORDERS = {
"A1001": {"status": "shipped", "carrier": "DHL", "eta": "2026-07-15"},
"A1002": {"status": "processing", "carrier": None, "eta": None},
}
@app.get("/orders/{order_id}")
def get_order(order_id: str):
order = ORDERS.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
return {"order_id": order_id, **order}Run it with uvicorn orders_api:app. Now wire it into the model as a tool. Here it is against the Anthropic API, but the shape is the same everywhere: describe the tool, let the model ask for it, run it, return the result.
# agent_direct.py — the tool definition and its handler live in your app.
import httpx
from anthropic import Anthropic
client = Anthropic()
tools = [
{
"name": "get_order_status",
"description": "Look up the current status of a customer order by its ID.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "The order ID, e.g. A1001"},
},
"required": ["order_id"],
},
}
]
def run_tool(name: str, args: dict) -> str:
if name == "get_order_status":
return httpx.get(f"http://localhost:8000/orders/{args['order_id']}").text
return "unknown tool"
messages = [{"role": "user", "content": "Where is order A1001?"}]
resp = client.messages.create(
model="claude-sonnet-5", max_tokens=1024, tools=tools, messages=messages,
)
# When resp.stop_reason == "tool_use", run the tool and send the result back:
for block in resp.content:
if block.type == "tool_use":
result = run_tool(block.name, block.input)
messages.append({"role": "assistant", "content": resp.content})
messages.append({
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": block.id, "content": result}],
})
resp = client.messages.create(
model="claude-sonnet-5", max_tokens=1024, tools=tools, messages=messages,
)Run uvicorn orders_api:app in one terminal and python agent_direct.py in another. The first response comes back with stop_reason == "tool_use", you run the tool, and the second response is plain text along the lines of:
Order A1001 has shipped with DHL and is estimated to arrive on 2026-07-15.If you get stop_reason == "end_turn" on the first call, the model answered without reaching for the tool. That usually means the description is too vague to match the question.
That is the entire direct path. The schema and the handler sit in one file you control. For a handful of tools in one application, this is the right amount of machinery. There is nothing to deploy, nothing to discover, and no protocol between you and your own code.
Path two: the same tool as an MCP server
Now expose the same capability over MCP. In Python the fastest way is FastMCP, which turns a plain function into a tool: the type hints become the input schema and the docstring becomes the description the model sees.
# orders_mcp.py — the same lookup, exposed as an MCP tool.
from fastmcp import FastMCP
import httpx
mcp = FastMCP("orders")
@mcp.tool()
def get_order_status(order_id: str) -> dict:
"""Look up the current status of a customer order by its ID."""
r = httpx.get(f"http://localhost:8000/orders/{order_id}")
r.raise_for_status()
return r.json()
if __name__ == "__main__":
mcp.run() # stdio transport by defaultNotice the server still calls the same FastAPI endpoint. The difference is what happens on the other side. Nothing about this server is specific to your application. A desktop client points at it with a few lines of config:
{
"mcpServers": {
"orders": {
"command": "python",
"args": ["orders_mcp.py"]
}
}
}And a hosted agent reaches a networked version of it over Streamable HTTP. With the Anthropic API that is the MCP connector, which lets the model call a remote server directly:
# agent_mcp.py — no per-tool wiring; the client speaks MCP.
from anthropic import Anthropic
client = Anthropic()
resp = client.beta.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
betas=["mcp-client-2025-11-20"],
mcp_servers=[{"type": "url", "name": "orders", "url": "https://your-host/mcp"}],
tools=[{"type": "mcp_toolset", "mcp_server_name": "orders"}],
messages=[{"role": "user", "content": "Where is order A1001?"}],
)Two things will bite here. r.raise_for_status() turns a 404 into an exception inside the tool, and FastMCP returns that to the model as a tool error rather than crashing the server, which is what you want: the model can say "I couldn't find that order" instead of the run dying. And the MCP connector needs both halves of the request. Sending mcp_servers without the matching tools=[{"type": "mcp_toolset", ...}] entry is rejected as a validation error, which is a confusing failure the first time you hit it.
The payoff is not visible with one tool and one client. It shows up the moment a second consumer appears. Your app, a teammate's Claude Desktop, and a coding agent can all use get_order_status without any of them writing the schema or the call. Add get_shipment_events to the server and every connected client discovers it without new integration code.
Choosing between them
The decision is about standardization and reuse, not about which is more modern.
Use a direct tool call when:
- You have a few tools, not a sprawling set.
- One application uses them, and you own both the app and the tools.
- There is no second consumer on the horizon.
- You want the least infrastructure: no server to run, deploy, or secure.
Reach for MCP when:
- The same tools need to serve more than one client or agent.
- The tool count is growing, or different teams own different tools.
- You want capabilities discovered dynamically rather than hardcoded per app.
- You need more than actions: MCP also carries resources and prompts.
- You want to plug into the ecosystem of prebuilt servers instead of building every integration.
A useful rule of thumb: start direct, extract to MCP when a second consumer shows up or the tool set outgrows a single file. The function body barely changes in the move; what you take on is hosting, auth, and a transport, and what you drop is a set of bespoke integrations.
Two things to know before you run this in anger
Cost. Every tool definition is tokens in your prompt on every turn, and an MCP server that exposes fifty tools puts fifty schemas in front of the model whether or not any of them get used. That is a per-request cost you pay for tools nobody called. If your tool list is long, look at whether the client supports filtering it before you look at anything else.
Trust. An MCP server is code you are letting a model invoke. Pointing a client at someone else's server means their tool descriptions are now part of your prompt, and a tool description is a place to hide an instruction. Run servers you control, or read the ones you don't, and keep anything that spends money or writes data behind the same explicit approval you would give a human intern on day one.
The short version
APIs are the foundation; MCP is a standard socket you can put in front of them so any model-facing client plugs in the same way. For a few tools in one app you control, wire them directly and move on. When the same tools have to reach many agents, or the integration glue starts to multiply, that is MCP earning its place.