◈Home ▸Blog ◇Contact
Home/Blog/When to Use MCP Instead of Function Calling
How toLLM EngineeringMCPTool CallingArchitecture

When to Use MCP Instead of Function Calling

10 min readBy Miloš Mitrović

Use plain function calling when one team owns the model loop and the tools and ships them in the same deploy. Use MCP when the tools sit behind a boundary you do not control the release of: another team, another process, another company, or an end user who installs them. The model does not care which you pick, because both paths hand it a name plus a JSON Schema and get back a name plus an arguments object. What changes is who can change the tool list without your deploy, and what you have to build to make that safe.

Key takeaways

  • MCP does not make a model better at calling tools. It standardises discovery, transport and authorization around the same call you were already making.
  • Decision rule: multiply the number of distinct clients by the number of teams that own tools. If the answer is 1, use function calling.
  • The price of MCP is a connection handshake, a transport choice, an OAuth story for HTTP, and protocol version negotiation you cannot skip.
  • Per exposed tool the context cost is identical. MCP degrades context in practice because installing another server is cheap and nobody uninstalls.
  • Put the logic in a plain typed function and write two thin adapters. Then this decision is reversible in a day instead of a quarter.

What MCP adds on top of a function call

MCP adds a connection lifecycle and a discovery step in front of the tool call you were already making. Before any tool runs, the client sends initialize with a protocol version, its capabilities and its implementation info, the server answers with its own, and the client sends notifications/initialized; over HTTP every subsequent request must carry an MCP-Protocol-Version header (MCP specification, Lifecycle). Only then does tools/list return the tool objects, each with name, description, inputSchema, and optionally outputSchema and annotations, and tools/call returns content blocks with an optional structuredContent field (MCP specification, Tools).

With provider function calling there is no discovery. You put an array of tool definitions in the request, the model returns a tool name and arguments, your code runs it and appends the result to the message list (OpenAI function calling guide, Anthropic tool use overview). The tool list is a literal in your source tree, which is exactly the property you are trading away.

// Function calling: the tool list is a literal in your request
{
  "tools": [
    {
      "name": "refund_order",
      "description": "Refund an order by id. Returns refund id and status.",
      "input_schema": {
        "type": "object",
        "properties": { "order_id": { "type": "string" } },
        "required": ["order_id"]
      }
    }
  ]
}

// MCP: the same list arrives at connect time, over JSON-RPC
req {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"my-agent","version":"1.0.0"}}}
res {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"billing","version":"1.0.0"}}}
req {"jsonrpc":"2.0","method":"notifications/initialized"}
req {"jsonrpc":"2.0","id":2,"method":"tools/list"}
res {"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"refund_order","inputSchema":{"type":"object"}}]}}

When plain function calling is the right answer

If the agent and its tools live in one repository and ship in one pipeline, function calling is correct and MCP is a hop that buys you nothing. The tools are private functions that touch your database, your queue, your internal HTTP services. A prompt change and a schema change land in the same pull request, which removes an entire class of version skew that MCP forces you to design for (see changing an MCP tool without breaking agents).

You also keep things that are awkward to reproduce across a protocol boundary. Tool handlers can share a request scoped database transaction with the rest of the turn. They can take rich Python or TypeScript objects instead of JSON round trips. They can raise typed exceptions that your loop maps to model visible text with full stack context. Tests stub a function, not a subprocess. If your answer to "who else will call this tool" is "nobody", stop here.

When MCP earns the extra layer

Reach for MCP the moment clients times tool owners exceeds one. Four situations make it pay:

  • Many clients, one tool set. Claude Code, an IDE, a desktop client and your own backend agent all need the same twelve tools. Writing four integrations against four provider SDKs is the thing MCP removes.
  • Tools owned by another team. The billing team ships tool changes on their cadence and you consume tools/list at connect time rather than redeploying to pick up a new capability. The notifications/tools/list_changed notification exists for exactly this.
  • End users install tools. If a customer configures which tools their agent has, you need a wire format, a transport and a consent model you did not invent. That is what adding a local or remote server to a client already does.
  • You need host capabilities. Server initiated model calls (sampling) and mid call user input (elicitation) have no equivalent in a provider tools array. A function cannot ask the host to prompt the user. An MCP server can.

What the extra layer costs

MCP costs you a handshake, a transport decision, an authorization story and a version negotiation, and none of them are optional in production. Budget them explicitly.

Connection setup. One round trip plus a notification before the first tool call, and for stdio a child process spawn per client session, which is tens to hundreds of milliseconds of Python or Node startup. It amortises over a long session and it is pure loss for a one shot request path.

Transport and state. stdio is simple and local. Streamable HTTP gets you a remote server and forces you to decide whether sessions exist at all, because stateless servers have to keep state somewhere else. See serving MCP over Streamable HTTP for the mechanics.

Authorization. Over HTTP the server acts as an OAuth 2.1 resource server. It must implement Protected Resource Metadata (RFC 9728), clients must send the RFC 8707 resource parameter, and the server must validate that the token was issued for it and must not forward that token upstream (MCP specification, Authorization). Over stdio the spec says do the opposite and take credentials from the environment. That is two credential paths to maintain. See adding OAuth to a remote server and why token passthrough is forbidden.

Version and namespace skew. A client may request an older protocol revision, the server must answer with a version it supports, and the client may disconnect if it cannot speak that version. Your tool names also become a shared namespace with every other server the user installed, which is how two servers end up fighting over the same tool name.

Does MCP cost more context than function calling

Per exposed tool, no. In practice, yes, because MCP makes adding tools a config line and nobody ever removes one. Tool definitions are input tokens on every request in both designs (Anthropic tool use overview). A definition with a modest schema and a useful description typically runs 100 to 300 tokens, so twenty tools is a few thousand tokens per turn and sixty is a five figure bill before the user has typed anything. Anthropic's write up on code execution with MCP describes agents connected to enough servers that definitions alone ran past a hundred thousand tokens before any work started.

Do not estimate this. Measure it, with the same tool array you are about to ship.

import anthropic

client = anthropic.Anthropic()
resp = client.messages.count_tokens(
    model="claude-sonnet-5",
    tools=TOOLS,                      # the exact array you send in production
    messages=[{"role": "user", "content": "refund order 1234"}],
)
print(resp.input_tokens)              # baseline; re-run with tools=[] for the delta

The fix is not to abandon MCP. It is to stop loading every tool on every turn, which is the argument in tool search versus code execution, and to keep results from eating the window, covered in stopping tool results from eating your context. Accuracy falls off well before the advertised limit, as context rot describes, so a large window is not an answer.

How to write the logic once and expose it twice

Put the work in a plain typed function, keep the JSON Schema next to it, and make both surfaces thin adapters over that function. This is the single decision that makes the MCP question cheap to get wrong.

# core.py  the only place business logic lives
from dataclasses import dataclass

@dataclass
class RefundArgs:
    order_id: str
    amount_cents: int | None = None

REFUND_SCHEMA = {
    "type": "object",
    "properties": {
        "order_id": {"type": "string", "description": "Order id, e.g. ord_1234"},
        "amount_cents": {"type": "integer", "minimum": 1},
    },
    "required": ["order_id"],
}

def refund_order(args: RefundArgs) -> dict:
    # validation, idempotency key, billing call
    return {"refund_id": "re_9", "status": "pending"}


# adapter_api.py  in-process function calling
TOOLS = [{
    "name": "refund_order",
    "description": "Refund an order by id. Returns refund id and status.",
    "input_schema": REFUND_SCHEMA,
}]

def dispatch(name: str, arguments: dict) -> dict:
    if name == "refund_order":
        return refund_order(RefundArgs(**arguments))
    raise KeyError(name)


# adapter_mcp.py  the same function over MCP
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("billing")

@mcp.tool()
def refund_order_tool(order_id: str, amount_cents: int | None = None) -> dict:
    """Refund an order by id. Returns refund id and status."""
    return refund_order(RefundArgs(order_id, amount_cents))

Two rules keep this honest. Adapters contain no business logic and no I/O, so a bug is never "only on the MCP path". Both adapters map failures the same way, returning a message an agent can act on rather than a stack trace (see errors agents can recover from). The official SDKs are built for this shape; the Python one is at modelcontextprotocol/python-sdk, and building a server with FastMCP covers the rest.

How to decide in ten minutes

Answer six questions and take the majority. Every row is a fact about your org chart or your latency budget, not about model quality.

QuestionFunction callingMCP
Who deploys the tools?Same pipeline as the agentA different team, or a customer
How many distinct clients call them?OneTwo or more, or any third party
Do end users install or configure tools?NoYes
Do tools need sampling, elicitation or roots?NoYes, there is no API equivalent
Is per call overhead under 50 ms part of the product?Yes, stay in processNo, a hop is acceptable
How often does the tool set change?With your releasesIndependently of your releases

If the table is split, the tie breaker is the second column of row one. Release ownership is the property MCP actually buys, and it is the one that is painful to bolt on later.

Trade-offs and what I would ship

For the first version of an internal agent I ship function calling, with the core plus adapter structure above, and I do not apologise for it. The common failure I see is the reverse: a team stands up an MCP server so their own backend can call a function three files away, and now pays process spawn, JSON serialisation, schema drift and a second credential path to reach code in the same repository. The protocol was designed for a boundary. If there is no boundary, you have added a boundary in order to cross it.

The opposite mistake is worse and less reversible. If tools will be consumed by clients you do not build, do not invent a bespoke HTTP tool protocol with your own discovery endpoint and your own auth. You will reimplement tools/list, capability negotiation, cancellation and progress badly, and no client will speak it. That is the case where I break my own default and start with MCP even when there is exactly one client today, because retrofitting approval flows, transports and OAuth onto a shipped integration costs more than the handshake ever will.

Two caveats on the recommendation. MCP moves your schemas into a compatibility contract, so treat a tool signature like a public API from day one, additive changes only. And MCP does not solve tool selection: a model that ignored your function will ignore your MCP tool for the same reasons, which is a description and naming problem, covered in why your agent never calls your tool. Pick the layer for who owns the deploy, then spend your effort on the schemas, because that is what the model actually reads.

Sources

M
Miloš Mitrović
Email Marketing for Ecommerce

Have a question or a project?

Whether it is about this post or a system you want built, I'm happy to talk.

Get in touch

404

Post not found. It may have been moved or the link is incorrect.

← Back to the blog
Summarize with AI
ChatGPT, Perplexity, and Grok open with the prompt ready to run. Claude, Gemini, and Copilot open a chat with the prompt copied; press Ctrl+V (Cmd+V on Mac) to paste. The full text is included, so it works even without web access.