Skip to content

21 min read

The direct function call baseline

On this page

A capability handed to a model can live in two places. It can be ordinary code inside the agent's own process — a function, plus a name, a plain-English description and a JSON schema for its arguments, sent to the model alongside the conversation so that it knows the tool is available. When the model asks for that tool, the agent's loop invokes a function it already holds in memory, which is what a direct function call means here. Or the same code can move into a separate process that speaks the Model Context Protocol (MCP), the open standard Anthropic published in late 2024 for connecting AI applications to tools and data. An MCP server is a process that advertises a list of tools — each with a name, a description and an argument schema — over a defined wire format, and an MCP client, the application hosting the model, connects to it, discovers what is on offer, and calls those tools over the network on the model's behalf. The protocol exists to solve an arithmetic problem in the ecosystem rather than one inside any single codebase: every AI application used to wire up every integration in its own code, so M integrations across N applications produced M×N pieces of bespoke glue, none of it portable. A protocol lets an integration written once be consumed by clients whose authors you never meet, which is why MCP usually reaches an engineer as configuration — an mcpServers block in the JSON config of Claude Desktop or an IDE, an internal registry of endpoints any agent team can point at, or a vendor's public server that customers attach their own assistant to.

That arithmetic is worth doing rather than gesturing at, because working it through also says where it stops paying. Without a protocol the work is a grid: one cell per application per integration, each cell a separate piece of glue, and the grid grows as the product of the two sides. With a protocol each integration is built once and each application implements the client side once, so the total grows as the sum of the two sides instead. A sum beats a product handily as soon as either side has more than a few entries in it, which is the whole case for a standard and it is a good case. Run the same comparison at one application and one integration and it inverts. The grid has a single cell; the protocol version has two pieces to build, deploy and keep in step with each other. The corner where the sum loses to the product is exactly the situation measured below.

Topics 6.10 and 6.11, on building an MCP server and on consuming one, cover those two crafts. The decision that comes before both is whether a given capability should cross a process boundary at all, and the only way to settle it with a measurement rather than a preference is to build the thing twice. That is expensive enough that nobody does it by accident and cheap enough — an afternoon each, for a capability the size of the one below — to be worth doing when the answer is close. Two implementations is the right number: skills and subagents are also candidates for a job like this one, and they arrive at stage 12, so comparing your code against an option you have not written produces an argument about what you imagine it would cost.

The function version, end to end

The capability under test is a support-ticket lookup: fetch and summarise a customer's recent tickets so that an internal support agent can reflect open issues before it drafts a reply. The whole of it is a query, a truncation, and a schema handed to the model. Nothing about it is unusual, which is the point:

python
# tickets.py — inside the agent service
from datetime import datetime, timedelta

MAX_TICKETS = 20

def recent_tickets(customer_id: str, days: int = 30) -> dict:
    since = datetime.utcnow() - timedelta(days=days)
    rows = db.fetch_all(
        """SELECT id, subject, status, priority, created_at,
                  left(first_message, 800) AS excerpt
             FROM tickets
            WHERE customer_id = :cid AND created_at >= :since
         ORDER BY created_at DESC
            LIMIT :limit""",
        cid=customer_id, since=since, limit=MAX_TICKETS,
    )
    return {
        "customer_id": customer_id,
        "window_days": days,
        "count": len(rows),
        "truncated": len(rows) == MAX_TICKETS,
        "tickets": [dict(r) for r in rows],
    }

RECENT_TICKETS_TOOL = {
    "name": "recent_tickets",
    "description": (
        "Return the customer's support tickets from the last N days, "
        "newest first, at most 20. Use before drafting a reply so the "
        "response reflects open issues. Returns an empty list if the "
        "customer has no tickets in the window; `truncated` is true "
        "when there were more than 20."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "customer_id": {"type": "string"},
            "days": {"type": "integer", "minimum": 1, "maximum": 180,
                     "default": 30},
        },
        "required": ["customer_id"],
    },
}

The dispatch in the agent loop is three lines, and the model never learns anything about how the function is reached:

python
if block.type == "tool_use" and block.name == "recent_tickets":
    result = recent_tickets(**block.input)
    tool_results.append({"tool_use_id": block.id,
                         "content": json.dumps(result)})

Everything the model sees — the name, the description, the argument schema, the shape of the returned object — is identical to what it would see over a protocol. Tool quality lives in that description and that schema, and both versions get exactly the same ones. If the two implementations differ in how reliably the agent uses the capability, the cause is somewhere other than the transport.

That equality is the control which makes everything after it mean something, and it is worth saying why it holds rather than asserting it. A model's entire view of a tool is four things: the name, the description, the argument schema, and the shape of what comes back. None of the four says where the code runs, or whether anything was serialised on the way. Two implementations presenting identical values for all four are therefore indistinguishable from inside the model, so any difference in how often the agent reaches for the capability, or how well it fills the arguments in, cannot have been caused by the boundary. Keeping those four byte-identical across the two branches is deliberate. Without it the exercise compares two tool designs while claiming to compare two architectures, and the better-written description wins whichever column it happens to sit in.

What the server version has to add

The server itself is not much bigger than the function, because the function is still in there:

python
# server.py
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("support-tickets")

@mcp.tool()
def recent_tickets(customer_id: str, days: int = 30) -> dict:
    """Return the customer's support tickets from the last N days,
    newest first, at most 20. ..."""
    return _recent_tickets(customer_id, days)

if __name__ == "__main__":
    mcp.run(transport="streamable-http")

The 269 extra lines are almost entirely around it. There is a Dockerfile and a Helm values file, because the process now has to run somewhere that is not the agent's pod. There is an authentication layer, because a network-reachable endpoint that reads any customer's tickets needs to know who is asking. There is a mapping from the caller's identity to a tenant scope, because the function inherited the agent's request context for free and the server has no request context to inherit. There is a health endpoint, a readiness probe, and a timeout policy. There is a client-side configuration entry that has to be deployed to the agent alongside the server, and a connection lifecycle around it.

Where the lines wentFunctionServer
Query and result shaping3434
Tool schema and description2611 (generated from the signature)
Agent-side dispatch30
Server bootstrap and transport022
Authentication and caller→tenant mapping088
Deployment (Dockerfile, chart, secrets)096
Health, readiness, timeouts031
Client configuration and connection handling047
Tests for the above811
Total71340

Line counts are a proxy and a coarse one; two of those blocks are configuration a platform team may already have templates for, which would make the server cheaper at your company than at the one in this example. The reason to write the number down anyway is that it stops the conversation from being conducted entirely in adjectives.

The breakdown is worth checking rather than reading, because it closes. Add the function column down its rows — thirty-four for the query, twenty-six for the schema, three for the dispatch and eight for the tests, with zeroes everywhere else — and it comes to the seventy-one printed at the bottom. Add the server column the same way and its rows come to three hundred and forty. Subtract the smaller total from the larger and the gap is two hundred and sixty-nine, which is the figure arrived at above the table from the opposite direction, by naming the things that had to be built. Two independent routes to one number, and no row left over on either of them.

A third route through the same table says what the gap is made of. Exactly one row is equal in both columns, the query and result shaping, and that row is the capability itself. Every other row differs. The schema block shrinks by fifteen lines because the framework generates it from the function's signature; the agent-side dispatch loses its three; the tests gain three; and five rows exist only on the server side — bootstrap, authentication, deployment, health, and client configuration — contributing two hundred and eighty-four between them. Net those movements together and two hundred and sixty-nine comes back a third time. Since the capability's own row contributes nothing to the difference, the difference is not a measurement of the feature. It measures the boundary and nothing else.

Because that breakdown closes with no residual row, it licenses something a bare total cannot. Every one of those lines is attributable to a named row, so a reader at a different company can strike the rows their own platform already owns and recompute rather than argue. Strike the deployment row on the grounds that a chart and a Dockerfile come out of a template, and the server's total falls by ninety-six while the gap falls to one hundred and seventy-three. That surviving figure is the one worth having the argument about, and it is still large. A total quoted without a breakdown cannot be adjusted this way at all, which is why it loses every conversation with somebody whose infrastructure differs from yours.

The eighty-eight authentication lines are the block most people assume a framework handles, so they are worth opening up. The function received the caller's identity without asking for it: the agent had already authenticated the support representative at the front door, put the tenant on the request context, and opened the database connection under a role whose row-level policy was scoped to that tenant. None of that survives a process boundary. The server receives an HTTP request carrying a token, and it has to verify the signature against the issuer, check the audience claim so that a token minted for a different service cannot be replayed against this one, map the subject to a tenant, and refuse by default when the mapping is absent. The tenant then has to be threaded down into the query as a parameter, because the in-process version got it from context and the server has no context to get it from. The ninety-six deployment lines are a Dockerfile, a chart with a values file per environment, a secret reference for the database credential the server now holds in its own name, and a network policy permitting the agent's pod to reach it.

Every item on that list descends from a single property, which is worth naming separately because it predicts the list rather than summarising it. A request context is an object in the memory of the process that built it, and memory does not cross a process boundary. Identity therefore has to be re-established from something the request itself carries, and each check in the sequence above closes one specific way of forging it: verifying the signature against the issuer stops a token anybody could mint, checking the audience stops a genuine token minted for a neighbouring service from being spent here, and denying by default stops a caller with no tenant mapping being handled as a caller entitled to every tenant. The same property reaches down into the deployment row as well. The function borrowed the agent's database connection, opened under a role the agent had already assumed on the request's behalf; a separate process has nothing to borrow, so it holds a credential in its own name, and a credential in its own name is a secret to store, reference and rotate for as long as the server exists.

A framework can do the first two of those checks, which is what makes the assumption that authentication comes for free so reasonable to hold. It cannot do the third. The mapping from a token's subject to a tenant is a fact about how your organisation is arranged rather than about the protocol, so no library ships with it, and it is what most of the lines in that row turn out to be.

The failure modes are the more honest column

Lines of code can be argued away. The set of things that can go wrong cannot, because each entry is a thing somebody has to detect, page on, or explain to a user.

Failure modeFunctionServer
Query returns wrong rowsyesyes
Database unavailableyesyes
Result too large for the context windowyesyes
Server process not runningnoyes
Connection refused, DNS, TLS expirynoyes
Credentials rejected or expirednoyes
Caller mapped to the wrong tenantnoyes
Client and server on incompatible tool schemasnoyes
Server deployed, client config not updatednoyes
Server rolled back, client still calling a removed toolnoyes

The top three are shared, which is the part people skip: an MCP boundary does not remove any failure the underlying capability already had. It adds a second class of failure that exists only because the capability now lives at the end of a network path owned by a process with its own lifecycle. Each of the seven additions is individually manageable — building and consuming servers is largely about managing exactly these — and the point is that there are seven of them in exchange for a capability that already worked.

That table partitions, and the partition is what turns it into an argument. Every row is either marked yes in both columns or marked yes only in the server column; there is no row the function carries and the server does not. Three rows are shared, seven are server-only, and three and seven are the ten the server column runs to — the two groups cover the column exactly, with nothing sitting outside them. Because the split closes, the seven stop being a list somebody assembled and become a price. None of them is a fact about looking up support tickets, since the capability's own ways of failing are the shared three and those are unchanged by where the code runs. All seven are facts about there being a boundary, and they would turn up around any capability moved across one.

The empty function-only category is not luck either, and seeing why makes the result predictable before the table is drawn. The server version still contains the function: the same query, the same truncation, the same result shape, with a transport wrapped around the outside. An implementation that contains another one inherits everything the contained one can do wrong, so the server's set of failure modes has to be a superset of the function's, and a superset can differ only by addition. The comparison could not have come out any other way. What the exercise measures is therefore the size of the addition, never whether there is one.

Latency follows the same shape. In this team's staging environment, the in-process call ran at a p50 of 41 ms and a p99 of 190 ms — the median call and the slowest one call in a hundred — both dominated by the database. Over the server, the same query measured p50 68 ms and p99 410 ms, with the extra time split between the HTTP round trip, JSON serialisation of a payload that was previously a Python dict, and connection establishment on the first call of a session. Twenty-seven milliseconds at the median is not a reason to decide anything by itself, and it is a real number that goes on the sheet next to the others.

Where those milliseconds come from is more useful than the figure, because the mechanism says how the figure will move at a different scale. Sixty-eight at the median against forty-one is a difference of twenty-seven, and none of it is database time: the query is the same query against the same database, which is the reason the first row of the line table reads the same on both sides. In process, the result is a Python dictionary handed to the caller by reference — nothing is copied and nothing is encoded. Across the boundary that dictionary has to be serialised to JSON, written to a socket, carried, and parsed back into an object on the far side. Two of those costs scale with the size of the payload, so the gap is not a constant to be added once; it grows with the number of tickets a customer turns out to have. Connection establishment is the exception, since a session pays it on its first call and never again, which lands it in the tail rather than in the median.

Two conditions travel with that figure and both of them narrow it. It was measured in one team's staging environment, which makes it a fact about that network and that database's load rather than a constant of the protocol; a different deployment gets a different number, and the way to find out is to measure rather than to quote this one. And it is a median, the call in the middle, so it says nothing about the slow call a user actually notices waiting for. The p99 figures sit on the sheet beside it for that reason, and a median difference is the weakest of the three columns to decide anything on.

An option held open is an option being paid for

The server version has three things the function version does not: a network-addressable endpoint that a process outside the agent can reach, its own deployment and release cycle independent of the agent's, and a standard protocol description that any MCP-capable client can consume without reading your source. All three are properties of the artefact. Whether any of them is a benefit depends on whether a caller exists that needs it, and each of the three has a different condition attached. The endpoint is a benefit when a process that cannot execute your code needs the capability. The independent release cycle is a benefit when somebody wants the capability to change without waiting for your deploy, and a liability when nobody does, since the only thing an independent cycle guarantees on its own is that two builds can disagree. The protocol description is a benefit when a client you did not write has to discover the tool and read its schema at runtime, and it is a document with one reader when every client is yours.

Holding the option open is not free, and that is the part the artefact-versus-benefit framing can obscure. The server exists from the day it merges, so somebody is patching its base image, rotating its database credential, carrying its pages, and keeping its chart current through cluster upgrades, all in service of a caller that could have written an import statement. If the second consumer arrives eighteen months later, the option cost the team eighteen months of upkeep before it paid anything. If the second consumer never arrives, the option cost them the upkeep and then a deletion conversation with whoever has since started depending on the endpoint.

This is what makes the "it is more reusable" argument hard to answer well. It is true as a description of the artefact and empty as a justification, because reuse is a benefit only when somebody reuses it. A standard interface with one consumer is a cost with an option attached, and the option is worth something only when you can name who would exercise it. The claim becomes worth something when there is a named second consumer, in a specific team, whose own deployment cannot contain your code. Short of that, the server author is describing a property of what they built rather than a benefit to anyone.

Laid out as steps the argument is short, and the shortness is why it holds up in a review. Reuse is a property of the artefact: it becomes true the moment the server exists, and it stays true whether or not anybody ever connects to it. A benefit is a change in somebody's situation, so a benefit requires somebody whose situation changes. The sentence therefore turns into evidence only when a beneficiary can be named, and until one is named it is a true statement with nothing hanging off it. Asking for that name is not scepticism about reuse being valuable. It is the only form in which the claim can be checked at all, and a claim that cannot be checked cannot lose an argument, which is what makes it so durable in proposals.

Two implementations answer a narrow question: for this capability, at this company, with this consumer, which is cheaper and which breaks in more ways. They do not say that MCP is a bad choice generally, and the axes on which the function wins here are exactly the axes that invert when the situation changes. Line count moves against the function as soon as a second team would otherwise write those 71 lines again, at which point the function column stops being a single number. The seven extra failure modes are the price of a boundary, and there are situations where you want the boundary badly enough to pay it — a capability whose credentials the caller must never hold, for one, which chapter 3 takes apart alongside ownership and change rate.

The comparison also has nothing to say about the other two shapes this capability could take. Skills and subagents are stage 12, and 12.16 is where this measurement gets rerun with four columns instead of two; anything asserted about them from here is a forecast rather than a count, which is the same objection this chapter makes to every argument built on a property of the artefact. The discipline that makes the exercise worth an afternoon is that it only reports on what was actually built, and the price of that discipline is that it stays silent about everything that was not.

It is worth being precise about what "cheaper" meant here, because the word usually gets attached to build effort alone. Both branches took an afternoon to write, and nearly all of the difference between them showed up in things that are not writing: the review path, an extra environment to keep in sync, a runbook entry, and a first on-call handover for a process that had not existed the week before. Build cost is the number quoted in proposals because it is the only one available before the work starts, and it is reliably the smallest of the numbers that end up mattering, which is the arithmetic chapter 5 rebuilds from fixed and variable costs.

What the measurement establishes is the direction of the burden of proof. The function is where you start; the server is what has to beat it; and 71 against 340, three shared failure modes against ten, and 41 ms against 68 are the margin it has to overcome. Nothing in those numbers is fatal to an MCP server, and none of them is overcome by an argument about the protocol — they are overcome by a fact about your situation, and the fact most likely to do it is a second consumer.

That word is doing more work than it looks like it is doing. Two clients that ship inside the same deployment are not two consumers in any sense that changes the decision, and a list of five callers can contain none at all. The test falls out of the definition above rather than sitting beside it as a second rule: a consumer counts when it needs the capability and cannot contain the code, because a caller that can contain the code has an import statement available and gets the capability without paying for a boundary. Two clients inside one deployment can each contain it, so neither is a beneficiary of the endpoint however separately the two were written. Five callers in five repositories that all ship from one pipeline fail on the same grounds. Counting callers is the wrong count, and the right question to put to each of them is which one could not have imported the file.

When MCP is the wrong answer — read the current version at https://daviddominguez.dev/learn/en/when-mcp-is-the-wrong-answer/the-direct-function-call-baseline

Back to top