When I built the MCP server for this project — it combines GitHub and DEV.to into a set of tools an agent can call — I had a decision to make before writing a single tool: talk to the low-level MCP protocol directly, or use FastMCP's decorator API. I've seen a few "your first MCP server" writeups lately walk through the low-level path because it's more "honest" about what MCP actually is under the hood — JSON-RPC over stdio, a capabilities handshake, typed request/response schemas. That's true, and it's a reasonable thing to want to understand. But I want to write about the other side: what it actually costs you in practice once you have more than one or two tools, because I went through both and the difference showed up fast.
what the low-level path actually asks you to write
Strip away the decorator and MCP is a JSON-RPC server. For every tool you add, you're responsible for:
- Registering the tool's name, description, and a JSON Schema for its inputs in a
list_toolshandler - Writing a
call_tooldispatcher that matches on tool name and unpacks arguments by hand - Serializing the return value into the
TextContent/ImageContentwrapper types MCP expects - Keeping the schema you wrote in step 1 in sync with the arguments you actually read in step 2, by hand, forever
None of that is hard in isolation. The problem is it's boilerplate that scales linearly with tool count and has zero connection to the actual logic of the tool. My server has 8 tools. Hand-rolled, that's 8 schema blocks plus a dispatcher if/elif chain plus 8 response-wrapping calls, all of which exist purely to satisfy the protocol, not to do anything a GitHub or DEV.to API call needs.
what it looks like with FastMCP
Here's an actual tool from server.py, unedited:
@mcp.tool()
def get_repo_stats(repo: str) -> dict:
"""Get stars, forks, watchers, open issues for enjoykumawat/<repo>."""
r = _gh(f"/repos/{GITHUB_USERNAME}/{repo}")
return {
"name": r["name"],
"stars": r["stargazers_count"],
"forks": r["forks_count"],
"watchers": r["watchers_count"],
"open_issues": r["open_issues_count"],
"language": r.get("language"),
"description": r.get("description"),
}
The type hint (repo: str) becomes the JSON Schema. The docstring becomes the tool description the agent sees when deciding whether to call it. The return type becomes the response shape. There's no schema to keep in sync by hand — it's derived from the same signature Python already type-checks against. Adding a ninth tool is: write a function, put @mcp.tool() above it, done. No dispatcher to update, no TextContent wrapping, no protocol-layer code touched at all.
Our ADR from when I made this call is blunt about the tradeoff:
Decision: Use
FastMCPfrommcp.server.fastmcpwith@mcp.tool()decorators. HTTP calls via stdliburllib.
Alternatives Considered: Low-level MCP server → rejected (unnecessary complexity for this use case).
Consequences:mcp[cli]is the only dependency. Server is runnable viapython server.pyormcp dev server.py.
"Unnecessary complexity for this use case" is doing a lot of work in that sentence, and I think it's the right lens: the low-level API exists because someone needs to write MCP client and server libraries, or needs protocol-level control (streaming partial results, custom capability negotiation, non-standard transports). If you're exposing 8 REST-API wrappers to an agent, you are not that someone, and the protocol-layer code you'd write has no relationship to your actual problem.
where the decorator approach did bite me
It's not free. Two real snags from actually running this server:
Return type coercion is implicit and easy to get subtly wrong. list_articles returns a list[dict] built by a list comprehension over the DEV.to API response. FastMCP serializes it fine — but the first version of that function forgot to cap per_page, and a caller passing per_page=1000 would have silently gotten whatever DEV.to's API actually returns for an out-of-range value (turns out it clamps, but I had to check, because FastMCP's schema generation from per_page: int = 10 doesn't enforce an upper bound just because the docstring says one exists):
@mcp.tool()
def list_articles(per_page: int = 10) -> list:
"""List your published DEV.to articles."""
articles = _dev(f"/articles/me?per_page={min(per_page, 30)}")
The min(per_page, 30) is doing real validation work that the type hint alone doesn't give you. The decorator gets you schema generation for free; it does not get you argument validation for free. Those are different things and it's easy to assume the first implies the second.
Errors inside a tool function become opaque to the agent unless you're deliberate about them. If _gh() raises urllib.error.HTTPError because a repo doesn't exist, FastMCP will catch it and turn it into a tool-call error the agent sees — but the agent sees "an error occurred," not "404, that repo name is wrong." I had to explicitly catch and reformat the interesting HTTP status codes in a couple of tools to get error messages an agent could actually act on instead of retry-blindly against.
Neither of those is a reason to go back to hand-rolled JSON-RPC — they're both things you'd still have to handle yourself at the low level, just with more code around them, not less. But "the decorator handles it" is only true for the schema-and-transport layer. Validation and error-message quality are still your job either way.
the actual takeaway
If you're deciding between the low-level MCP SDK and FastMCP for a server that's fundamentally "wrap some REST APIs as tools," the decorator API isn't the beginner's shortcut that graduates to the "real" low-level API once you're serious — it's the correct tool for that shape of problem, full stop. The complexity the low-level API buys you (protocol control, custom transports, non-standard capability negotiation) is complexity you pay for whether or not you need it. I needed 8 tools that call two REST APIs. FastMCP got me there with schema generation I didn't write and didn't have to keep in sync — and the two real gaps I hit (argument validation, error-message shaping) were mine to solve regardless of which API I'd started from.
Top comments (1)
Agreed that the decorator layer is the right abstraction here. One useful next step is making the generated schema semantically strict: constrained integers for pagination, enums for closed option sets, and explicit response models with stable error codes such as
not_found,rate_limited, andupstream_unavailableplus aretryableflag. Then contract-testlist_toolsagainst boundary inputs and actual outputs. That keeps the signature, runtime validation, and agent recovery behavior aligned instead of treating valid JSON as a complete tool contract.