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.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Discovery, iteration and context have different costs
Three differences explain why an interface that suits a developer may burden an agent.
| Dimension | Human developer | Agent |
|---|---|---|
| Discovery | Reads documentation and amortizes that work | Commonly receives tool names, schemas and descriptions on connection |
| Iteration | Writes a script that calls known routes | Spends additional model turns choosing and making calls |
| Context | Draws on accumulated experience | Works 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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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:
- Pick a few key endpoints and expose them through a converter or direct wrapper.
- Establish that an agent can discover and use a tool at all.
- Replace the raw endpoint mirror with curated workflows once that integration works.
- 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.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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
instructionsfield 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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
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.
Suggest correction
This note stays in this page until you copy or download it. Nothing is submitted; reloading clears the draft.
Resources
From the talk
Workflow-first tool design, context budgeting, documentation and permission guidance from Block's MCP deployments.
Python framework for building MCP servers and clients.
Jeremiah Lowin's companion argument for designing agent interfaces instead of exposing raw REST endpoints.
Cloudflare's explanation of using generated code to compose MCP tool calls.
On-demand tool discovery and code-based orchestration that keep intermediate results outside model context.
Further reading
- MCP Tools specification — June 2025Documentation
Historical protocol reference for tool discovery, invocation and tool metadata.
Updates since the talk
Experimental task support introduced in the November 2025 specification, including negotiated capabilities, polling and deferred results.
Read the transcript and explore slides
Loading transcript and slides…