AI Engineer Code 2025

Your MCP Server is Bad and You Should Feel Bad

Read the talk

Your MCP Server is Bad and You Should Feel Bad

A useful MCP server curates workflows, arguments and context for its agent user. Jeremiah Lowin shows how to move beyond API wrappers—and where client behavior changes the design.

From a talk by Jeremiah Lowin

Before you start: Familiarity with Python functions, API calls and basic LLM tool calling will help you follow the examples.

What makes a working MCP server a useful product?

Making tools available is only the beginning. What makes the resulting server useful to the agent that must discover and call them? Jeremiah Lowin approaches that question through years of building orchestration software: first as an Apache Airflow PMC member, then as Prefect’s founder and CEO, and later through the Marvin agent framework and FastMCP. Maintaining FastMCP gives him a view of how developers actually build these interfaces. Lowin reports 1.5 million FastMCP downloads on the day before the talk.

Slide with a FastMCP daily-download line chart showing growth and large spikes toward the right.Open full source frame
FastMCP daily downloads under “I’ve seen a lot of MCPs.”

FastMCP appeared days after MCP’s introduction, and a version entered Anthropic’s official SDK. By the recording, Lowin was positioning his separately maintained framework as the high-level interface, with the SDK responsible for low-level primitives. He described removing the confusing shared FastMCP naming as a future change. That maintenance experience also exposed many poorly designed servers. The talk’s Futurama-inspired title is an invitation to improve them, rather than an insult.

An MCP server is a user interface for an agent. It deserves the same attention to user experience, capabilities and workflows as a human-facing product. The objection that an AI should be able to use any API assumes too much competence and overlooks how humans work: they usually put a website, SDK, client or mobile app between themselves and the API. Agents are powerful but fallible users, and their interfaces should account for that.

1:221:34
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

1:22 · section reference included

Discovery, iteration and context have different costs

Three differences explain why an interface that suits a developer may burden an agent.

DimensionHuman developerAgent
DiscoveryReads documentation and amortizes that workCommonly receives tool names, schemas and descriptions on connection
IterationWrites a script that calls known routesSpends additional model turns choosing and making calls
ContextDraws on accumulated experienceWorks within a bounded active context

A developer can inspect Swagger, identify the necessary routes and encode their sequence once. An agent may rediscover the interface whenever it connects. Each additional model turn adds latency and, depending on caching, processes the accumulated history again. Eagerly loading definitions is a common client behavior, not a requirement that every MCP definition enter model context.

Lowin uses a 200,000-token window to illustrate bounded working memory, alongside knowledge stored in model weights. His loose Apollo-computing analogy asks developers to treat that capacity as scarce even when the system’s behavior looks impressive. An agent may find the needle in a haystack, but the interface should not make it inspect every piece of hay first.

The design verb is curate: select and shape the information the agent needs. MCP supplies a standard connection between models and tools or data—the familiar USB-C analogy—and a way to control discovery and action. Actual clients constrain what servers can achieve. FastMCP makes the basic plumbing small enough that Lowin’s introductory example simply exposes a tool asking whether Washington, DC’s subway is on fire; the demonstration answers yes. Getting a callable function onto a server is easier than deciding what that function should do.

5:566:06
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

5:56 · section reference included

Turn order lookup into one agent story

An audience member describes the common shortcut: wrap an API, stringify its response and call the result an MCP server. Even a hand-built server can have the same product problem. Like a website with confusing navigation or an all-Flash interface, it may technically work while making ordinary tasks unnecessarily difficult. Lowin recommends Block’s Playbook for Designing MCP Servers and GitHub’s published guidance as examples of developing better conventions; he also discloses that the Block team had been a customer of his data business for six years.

The workshop’s order-status example makes the cost concrete. Separate decorated Python functions expose operations for discovering a user, retrieving and filtering orders, and checking status. A developer would call them in a known sequence and return a user-facing answer. The exposed order-status workflow requires at least three agent round trips in Lowin’s example. The agent must also infer the sequence and supply correctly formatted arguments at every step.

Start instead with the outcome: track the latest order given an email address. This is an agent story—a concrete objective pursued by an autonomous user with limited context. Deterministic orchestration belongs in code when the sequence is already known. An LLM remains useful as an orchestrator when the algorithm is unknown or cannot readily be expressed programmatically; routine order lookup is an expensive place to require that flexibility.

Name the tool so the agent can select it at the right moment. An explanatory name such as track_latest_order communicates more than a generic operation name. Block expresses the same principle as designing top-down from workflows rather than bottom-up from endpoints. In Lowin’s refactor, the three API calls still happen, but they sit inside one agent-facing tool. The server takes responsibility for a sequence it already knows how to perform.

10:4811:26
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

10:48 · section reference included

Make the valid arguments obvious

A single workflow tool can still be difficult to call if its input is config: dict. The agent has to reconstruct an object whose conventions may live elsewhere. Duplicating those conventions in a system prompt or subagent definition creates another problem: change the server and the two descriptions can disagree. A fully annotated Pydantic model improves on an untyped dictionary, but does not remove the burden of constructing a complex argument.

Client behavior can compound that burden. Lowin reports that Claude Desktop had sent structured object arguments as strings, prompting FastMCP to attempt deserialization when a parameter clearly expected an object. He was unsure whether the client issue remained at recording time. This was a compatibility workaround, not a reason to prefer string-encoded objects.

Prefer top-level primitives, descriptive names and strong defaults: email, include_canceled and a constrained format. When the valid choices are known, Python’s Literal or an enum conveys more than format: str = "basic". For the order example, the following small implementation shows the combined interface using local teaching records; the lookup, order selection and status retrieval remain internal.

python

from typing import Literal
from fastmcp import FastMCP

mcp = FastMCP("Orders")

USERS = {"alex@example.com": "user-1"}
ORDERS = {
    "user-1": [
        {"id": "order-42", "created": "2025-11-20", "canceled": False},
        {"id": "order-43", "created": "2025-11-21", "canceled": True},
    ]
}
STATUSES = {"order-42": "shipped", "order-43": "canceled"}


def find_user(email: str) -> str:
    return USERS[email]


def latest_order(user_id: str, include_canceled: bool) -> dict:
    candidates = [
        order for order in ORDERS[user_id]
        if include_canceled or not order["canceled"]
    ]
    return max(candidates, key=lambda order: order["created"])


def order_status(order_id: str) -> str:
    return STATUSES[order_id]

The public function can then expose the workflow without exposing its internal operations. The default basic format returns the status alone; detailed includes the selected order’s ID and creation date:

python

@mcp.tool
def track_latest_order(
    email: str,
    include_canceled: bool = False,
    format: Literal["basic", "detailed"] = "basic",
) -> str:
    """Track the latest order for an email, excluding canceled orders by default."""
    user_id = find_user(email)
    order = latest_order(user_id, include_canceled)
    status = order_status(order["id"])
    if format == "detailed":
        return f"Order {order['id']} (created {order['created']}): {status}"
    return status
“Flatten your arguments” slide contrasting configuration dictionaries, mystery arguments, and over-configuration with primitives, typed enums, and strong defaults.Open full source frame
Flatten arguments with top-level primitives, typed enums, and strong defaults.
18:2118:40
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

18:21 · section reference included

Documentation and errors shape the next action

Without instructions, an agent guesses how a server works, and those guesses accumulate in its history. Document both the server and its tools, but choose examples carefully: models may reproduce incidental properties as well as intended usage. Lowin’s tag-generation example repeatedly produced two tags despite instructions requesting at least ten, because a documentation example contained two. An audience member proposes out-of-distribution examples to weaken that pattern. Lowin considers the idea reasonable, without claiming it reliably overcomes the influence of implicit patterns.

Errors are prompts. An empty ValueError or a cryptic numeric error gives the model little information beyond the fact that its attempt failed. The response becomes input to its next decision, so explain what went wrong and how to recover. For the same order lookup, an informative missing-user error could be:

python

from fastmcp.exceptions import ToolError


def find_user(email: str) -> str:
    user_id = USERS.get(email)
    if user_id is None:
        raise ToolError(
            "No customer matches this email. Ask for the email used "
            "at checkout before trying again."
        )
    return user_id

This gives the agent a next action instead of encouraging an unchanged retry.

For an unavoidably complex API, Lowin suggests an experiment: keep the initial description concise and return specific recovery instructions for common failures. That progressively discloses information according to the mistake, at the cost of accepting an unsuccessful first call. He does not recommend making failure the default discovery mechanism. Error wording also affects whether an agent continues using a tool; an alarming response may persuade it that the tool is permanently unusable. Full docstrings, useful examples and constructive errors work together.

Tool annotations provide another channel for communicating intent. The MCP Tools specification includes readOnlyHint, which clients may use when deciding how to present permissions. Lowin reports that ChatGPT developer mode requested extra permission when this annotation was absent, treating the tool as potentially having side effects. Mark a genuinely read-only lookup accordingly:

python

@mcp.tool(annotations={"readOnlyHint": True})
def get_order_status(order_id: str) -> str:
    """Return an existing order's status without changing it."""
    return STATUSES[order_id]

The hint describes behavior; it does not grant authorization or enforce read-only access.

21:4421:55
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

21:44 · section reference included

A handshake can leave no room to work

Context belongs to the whole agent session, not exclusively to one server. Lowin repeats a circulating estimate of roughly 200,000 tokens for a GitHub-server handshake, explicitly introducing it as a meme rather than a measurement. His broader point is that frameworks and clients must find ways to offer abundant functionality without loading all of it at once; simply doing less cannot be the only answer.

A customer wanted to expose 800 API endpoints. Under an assumed 200,000-token context budget, 800 tools leave just 250 tokens each for names, schemas and documentation, with nothing left for the task.

200,000 tokens800 tools=250 tokens per tool\frac{200{,}000\ \text{tokens}}{800\ \text{tools}} = 250\ \text{tokens per tool}

Even perfect adherence to that allowance would consume the entire window before the agent received useful working context or connected to another server. The calculation exposes a product constraint, not merely a documentation problem.

Set a budget even when the eventual client is unknown, and economize on the initial description. Moving occasional recovery instructions into error responses is one way to defer information. The larger opportunity is to change discovery: the common eager handshake loads descriptions together, whether or not the upcoming task needs them.

27:2627:37
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

27:26 · section reference included

Reveal the relevant tools—and know your client

An audience implementation begins with a service catalog—Google, Notion and Slack—then exposes details through a separate describe step when a service is needed. The audience participant reports 95% less upfront context consumption across many services with this progressive-discovery approach. The discussion supplies no reproducible baseline or tokenizer, so the figure describes that participant’s experience.

Lowin welcomes the design but separates protocol support from client behavior. He reports that Claude Desktop cached initially discovered tools in SQLite and ignored later notifications that should have enabled changes. Discovery implemented through a tool call could get around that particular limitation. It also creates meta-tools: tools that describe other tools, and sometimes invoke them. Their schemas can reintroduce the complex argument construction that flattening was meant to avoid. Progressive discovery therefore needs its own careful interface design.

Credential-based masking addresses a different source of excess context. An audience member asks about hiding 28 Kubernetes tools that the caller cannot access. MCP leaves the implementation behind tools/list to the server; it does not prescribe a filtering policy. Lowin describes an overridable FastMCP middleware hook and says Prefect’s commercial products support per-tool masking on arbitrary criteria. Protocol, framework and product each have a different role in that decision.

Could the customer’s 800 endpoints simply be divided among several servers? Lowin’s answer is that all the desired information still would not fit—and the customer did not need all of it. The problem became deciding what product the agent actually needed. He anticipates a shift from talking about MCP transport to talking about context products. Owning a mobile app’s agent client, or choosing a mandatory internal client, enables more deliberate discovery and budget management. He offers no general way to fit the full interface into an arbitrary external client’s context.

30:0930:12
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

30:09 · section reference included

Count tools from the agent’s perspective

Tool count is a useful warning signal, not a sufficient quality measure. Lowin puts GitHub’s server at roughly 170 tools in his account, while pointing to GitHub’s work on semantic routing as a reason a large interface deserves more careful consideration than automatic rejection. The cited routing work concerns selection in Copilot and VS Code, rather than an intrinsic capability of every GitHub MCP server. Before accepting a large inventory, ask whether admin and user workflows should be separated, whether namespaces would help, or whether different servers serve genuinely different users.

Lowin’s aspirational target is 5–15 tools, with roughly 50 tools per agent a warning threshold requiring care and evaluation. He explicitly corrects the slide’s per-server framing: two servers exposing 50 tools each give the same agent 100 tools. The aggregate interface is what matters, and these numbers are his design heuristics rather than measured universal limits.

Lowin illustrates the curation journey through two posts by Kelly Kohlleffel, at Fivetran, published about a month apart. The reported redesign reduced 188 tools across a data-stack MCP setup to five. The scope spans multiple data-stack servers, rather than solely a Fivetran server. Read together, the posts capture two distinct achievements: making the integration work, then making it work well for an agent.

35:3435:44
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

35:34 · section reference included

Bootstrap from REST, then design the product

Lowin’s Stop Converting Your REST APIs to MCP comes with an apparent contradiction: he introduced FastMCP’s popular conversion feature and intends to keep it. Automatic conversion is useful for proving connectivity. It becomes a problem when the generated endpoint mirror is treated as the finished agent interface.

The practical sequence is incremental:

  1. Pick a few key endpoints and expose them through a converter or direct wrapper.
  2. Establish that an agent can discover and use a tool at all.
  3. Replace the raw endpoint mirror with curated workflows once that integration works.
  4. Ship the resulting product interface, rather than the unedited REST translation.

Lowin starts this way himself. Keeping the initial integration small avoids introducing several new failure modes before basic tool use works.

“Stop converting REST APIs to MCP servers” slide contrasting automatic endpoint conversion with bootstrapping, refactoring, curating endpoints, and shipping a product.Open full source frame
Use generators to bootstrap, then refactor and curate before shipping.

The same discipline applies after the first version. Avoid large payloads, unnecessary choices and tightly coupled arguments where possible. Treat instructions as deliberately designed context. Lowin regards context capacity as the hard boundary; the other guidelines admit practical tradeoffs. Experimental tools should be removed when they stop earning their place, rather than accumulating alongside old and V2 variants. A growing API inventory can be reasonable for developers while becoming an increasingly confusing interface for an agent. Treat the server as a user interface throughout its life.

38:0138:06
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

38:01 · section reference included

Argument dependencies and documentation placement

The closing questions make argument coupling concrete. Suppose a tool accepts both a file type and a processing method. If the file type determines which processing methods are valid, the parameters are tightly coupled: individually valid values can form an invalid combination. That cross-parameter rule is another constraint the agent must remember.

Where should the instructions for such rules live? Client ownership changes the answer.

  • Known client: Lowin sometimes places workflow documentation in files or Claude skills when he knows Claude Code will consume it. Infrequent instructions can then remain outside the initial context.
  • Unknown client: Tool docstrings should carry the information needed to use the tool. Server authors cannot depend on a particular agent-side configuration.
  • Server-wide guidance: The server’s instructions field can hold concise high-level information. Lowin reports that most clients retrieve it on connection, but some ignore it; his team had filed bugs about that behavior.

The server-wide field is another opportunity to explain the product, not an invitation to include a novel.

43:0043:03
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

43:00 · section reference included

Background execution changes who waits

Asked about forthcoming asynchronous operations, Lowin describes himself as closely connected to the MCP core committee, though not a member. His colleague Adam had presented on the topic the previous evening. Lowin introduces SEP-1686 as a proposal for background tasks across MCP operations, rather than a tool-only facility.

In his proposal-era explanation, the client opts into asynchronous execution and takes responsibility for checking progress, polling and collecting the result. Discovering the tool and expressing its inputs still require a usable interface. The server designer’s question is therefore whether the client should block waiting for the result—not whether Python can run work in the background. The subsequently released Tasks specification introduced experimental, negotiated support for tool calls, sampling and elicitation, rather than every operation; either peer can be the requestor.

A brief audience exchange then returns to model behavior after a code-analysis tool encounters an error in a person’s code. Lowin recognizes a recurring problem worth investigating further. No concrete remedy emerges from that exchange.

45:2945:35
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

45:29 · section reference included

Ask for missing input during execution

Elicitation lets a running tool formally request additional structured input from its client. A common use is asking for approval before an irreversible side effect. That can produce a better interaction than demanding every possible argument before execution begins, but a server must account for whether the client supports the request. Lowin warns that unsupported elicitation can interrupt an otherwise usable workflow.

The client’s job is not always simple. A user-facing application can show a form; a background or automated client needs a policy for deciding who supplies the answer. Having an LLM fill the form does not necessarily satisfy the underlying need. With suitable client support, elicitation could request processing options after the file type is known, or obtain confirmation before a destructive action. Lowin contrasts this with a confirm=False argument that the model must explicitly change: model acknowledgment may affect its behavior, but it is not human approval.

48:2148:28
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

48:21 · section reference included

Direct tools and code mode

An audience member already builds API-calling tools in LangGraph or the OpenAI SDK and controls the agent loop. Does that setup need MCP? Lowin says no. He anticipates that a more mature MCP ecosystem could offer useful observability and diagnosis of tool failures, especially when an agent framework returns failures to the model for recovery without making them easy for developers to inspect. He acknowledges framework differences and presents that benefit as a forecast. A developer controlling the whole loop can keep direct tools; the interface-design principles apply to ordinary Python functions too.

The final question explores another way to reduce orchestration overhead: code mode. Lowin credits Cloudflare’s Code Mode article, followed by Anthropic’s code-execution treatment. Instead of choosing every tool call in a separate model turn, the LLM writes code that invokes tools in sequence. This can sidestep some of the repeated interaction costs discussed earlier, but introduces the responsibilities of executing and sandboxing generated code.

A colleague had built a FastMCP extension for this approach and distributed it separately while the team considered how it fit the framework. Lowin describes plans to expose it through an experimental or optimization facility, but does not confirm its exact CLI name or whether it had shipped. Code mode broadens the available design choices: known workflows can remain deterministic server code, while generated orchestration can help when flexibility warrants its execution and sandboxing costs.

50:2750:38
Suggest correction

This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.

50:27 · section reference included

Resources

From the talk

Updates since the talk

  • Experimental task support introduced in the November 2025 specification, including negotiated capabilities, polling and deferred results.

Transcript download
Read the transcript and explore slides

Loading transcript and slides…