{"slug": "i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-i-m-glad-i-t", "title": "I Almost Hand-Rolled JSON-RPC for an MCP Server. Eight Tools Later I'm Glad I Didn't.", "summary": "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.", "body_md": "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`\n\n'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.\n\nStrip away the decorator and MCP is a JSON-RPC server. For every tool you add, you're responsible for:\n\n`list_tools`\n\nhandler`call_tool`\n\ndispatcher that matches on tool name and unpacks arguments by hand`TextContent`\n\n/`ImageContent`\n\nwrapper 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`\n\nchain 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.\n\nHere's an actual tool from `server.py`\n\n, unedited:\n\n``` php\n@mcp.tool()\ndef get_repo_stats(repo: str) -> dict:\n    \"\"\"Get stars, forks, watchers, open issues for enjoykumawat/<repo>.\"\"\"\n    r = _gh(f\"/repos/{GITHUB_USERNAME}/{repo}\")\n    return {\n        \"name\": r[\"name\"],\n        \"stars\": r[\"stargazers_count\"],\n        \"forks\": r[\"forks_count\"],\n        \"watchers\": r[\"watchers_count\"],\n        \"open_issues\": r[\"open_issues_count\"],\n        \"language\": r.get(\"language\"),\n        \"description\": r.get(\"description\"),\n    }\n```\n\nThe type hint (`repo: str`\n\n) 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()`\n\nabove it, done. No dispatcher to update, no `TextContent`\n\nwrapping, no protocol-layer code touched at all.\n\nOur ADR from when I made this call is blunt about the tradeoff:\n\nDecision:Use`FastMCP`\n\nfrom`mcp.server.fastmcp`\n\nwith`@mcp.tool()`\n\ndecorators. HTTP calls via stdlib`urllib`\n\n.\n\nAlternatives Considered:Low-level MCP server → rejected (unnecessary complexity for this use case).\n\nConsequences:`mcp[cli]`\n\nis the only dependency. Server is runnable via`python server.py`\n\nor`mcp dev server.py`\n\n.\n\n\"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.\n\nIt's not free. Two real snags from actually running this server:\n\n**Return type coercion is implicit and easy to get subtly wrong.** `list_articles`\n\nreturns a `list[dict]`\n\nbuilt 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`\n\n, and a caller passing `per_page=1000`\n\nwould 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`\n\ndoesn't enforce an upper bound just because the docstring says one exists):\n\n``` php\n@mcp.tool()\ndef list_articles(per_page: int = 10) -> list:\n    \"\"\"List your published DEV.to articles.\"\"\"\n    articles = _dev(f\"/articles/me?per_page={min(per_page, 30)}\")\n```\n\nThe `min(per_page, 30)`\n\nis 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.\n\n**Errors inside a tool function become opaque to the agent unless you're deliberate about them.** If `_gh()`\n\nraises `urllib.error.HTTPError`\n\nbecause 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.\n\nNeither 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.\n\nIf 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.", "url": "https://wpnews.pro/news/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-i-m-glad-i-t", "canonical_source": "https://dev.to/enjoy_kumawat/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-im-glad-i-didnt-17mj", "published_at": "2026-07-18 03:36:06+00:00", "updated_at": "2026-07-18 03:59:13.019426+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "ai-agents"], "entities": ["FastMCP", "MCP", "GitHub", "DEV.to"], "alternates": {"html": "https://wpnews.pro/news/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-i-m-glad-i-t", "markdown": "https://wpnews.pro/news/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-i-m-glad-i-t.md", "text": "https://wpnews.pro/news/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-i-m-glad-i-t.txt", "jsonld": "https://wpnews.pro/news/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-i-m-glad-i-t.jsonld"}}