cd /news/developer-tools/i-almost-hand-rolled-json-rpc-for-an… · home topics developer-tools article
[ARTICLE · art-64321] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

I Almost Hand-Rolled JSON-RPC for an MCP Server. Eight Tools Later I'm Glad I Didn't.

A developer building an MCP server that combines GitHub and DEV.to APIs chose FastMCP's decorator API over the low-level JSON-RPC protocol. With eight tools, the decorator approach eliminated boilerplate like schema blocks and dispatcher chains, deriving schemas from Python type hints and docstrings. The developer notes that while the low-level API is necessary for protocol-level control, it adds unnecessary complexity for wrapping REST APIs.

read5 min views1 publishedJul 18, 2026

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.

Strip away the decorator and MCP is a JSON-RPC server. For every tool you add, you're responsible for:

list_tools

handlercall_tool

dispatcher that matches on tool name and unpacks arguments by handTextContent

/ImageContent

wrapper types MCP expectsNone 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.

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:UseFastMCP

frommcp.server.fastmcp

with@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.py

ormcp 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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @fastmcp 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/i-almost-hand-rolled…] indexed:0 read:5min 2026-07-18 ·