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, defined honestly

Direct tool calling. Every model API that supports tools works the same way. You describe each tool as a JSON schema, send it with your request, and the model replies with a structured request to call one. 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. 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.

That is the whole case for MCP in one line. It is a standardization and reuse play. If you have nothing to standardize or reuse yet, it is pure overhead.

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,
        )

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 default

Notice 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?"}],
)

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 client gets it for free.

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. You rarely regret starting simple, and the migration is mechanical because the function body barely changes. What changes is who is allowed to call it, and how many bespoke integrations you no longer have to maintain.

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.