{"id":3674,"date":"2026-07-18T03:49:05","date_gmt":"2026-07-18T03:49:05","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/18\/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-im-glad-i-didnt\/"},"modified":"2026-07-18T03:49:05","modified_gmt":"2026-07-18T03:49:05","slug":"i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-im-glad-i-didnt","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/07\/18\/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-im-glad-i-didnt\/","title":{"rendered":"I Almost Hand-Rolled JSON-RPC for an MCP Server. Eight Tools Later I&#8217;m Glad I Didn&#8217;t."},"content":{"rendered":"<div>\n<div>\n<div data-article-id=\"4170778\" id=\"article-body\">\n<p>When I built the MCP server for this project \u2014 it combines GitHub and DEV.to into a set of tools an agent can call \u2014 I had a decision to make before writing a single tool: talk to the low-level MCP protocol directly, or use <code>FastMCP<\/code>&#8216;s decorator API. I&#8217;ve seen a few &#8220;your first MCP server&#8221; writeups lately walk through the low-level path because it&#8217;s more &#8220;honest&#8221; about what MCP actually is under the hood \u2014 JSON-RPC over stdio, a capabilities handshake, typed request\/response schemas. That&#8217;s true, and it&#8217;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.<\/p>\n<h3> <a name=\"what-the-lowlevel-path-actually-asks-you-to-write\" href=\"#what-the-lowlevel-path-actually-asks-you-to-write\"> <\/a> what the low-level path actually asks you to write <\/h3>\n<p>Strip away the decorator and MCP is a JSON-RPC server. For every tool you add, you&#8217;re responsible for:<\/p>\n<ul>\n<li>Registering the tool&#8217;s name, description, and a JSON Schema for its inputs in a <code>list_tools<\/code> handler<\/li>\n<li>Writing a <code>call_tool<\/code> dispatcher that matches on tool name and unpacks arguments by hand<\/li>\n<li>Serializing the return value into the <code>TextContent<\/code>\/<code>ImageContent<\/code> wrapper types MCP expects<\/li>\n<li>Keeping the schema you wrote in step 1 in sync with the arguments you actually read in step 2, by hand, forever<\/li>\n<\/ul>\n<p>None of that is hard in isolation. The problem is it&#8217;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&#8217;s 8 schema blocks plus a dispatcher <code>if\/elif<\/code> 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.<\/p>\n<h3> <a name=\"what-it-looks-like-with-fastmcp\" href=\"#what-it-looks-like-with-fastmcp\"> <\/a> what it looks like with FastMCP <\/h3>\n<p>Here&#8217;s an actual tool from <code>server.py<\/code>, unedited: <\/p>\n<div>\n<pre><code><span>@mcp.tool<\/span><span>()<\/span> <span>def<\/span> <span>get_repo_stats<\/span><span>(<\/span><span>repo<\/span><span>:<\/span> <span>str<\/span><span>)<\/span> <span>-&gt;<\/span> <span>dict<\/span><span>:<\/span> <span>\"\"\"<\/span><span>Get stars, forks, watchers, open issues for enjoykumawat\/&lt;repo&gt;.<\/span><span>\"\"\"<\/span> <span>r<\/span> <span>=<\/span> <span>_gh<\/span><span>(<\/span><span>f<\/span><span>\"<\/span><span>\/repos\/<\/span><span>{<\/span><span>GITHUB_USERNAME<\/span><span>}<\/span><span>\/<\/span><span>{<\/span><span>repo<\/span><span>}<\/span><span>\"<\/span><span>)<\/span> <span>return<\/span> <span>{<\/span> <span>\"<\/span><span>name<\/span><span>\"<\/span><span>:<\/span> <span>r<\/span><span>[<\/span><span>\"<\/span><span>name<\/span><span>\"<\/span><span>],<\/span> <span>\"<\/span><span>stars<\/span><span>\"<\/span><span>:<\/span> <span>r<\/span><span>[<\/span><span>\"<\/span><span>stargazers_count<\/span><span>\"<\/span><span>],<\/span> <span>\"<\/span><span>forks<\/span><span>\"<\/span><span>:<\/span> <span>r<\/span><span>[<\/span><span>\"<\/span><span>forks_count<\/span><span>\"<\/span><span>],<\/span> <span>\"<\/span><span>watchers<\/span><span>\"<\/span><span>:<\/span> <span>r<\/span><span>[<\/span><span>\"<\/span><span>watchers_count<\/span><span>\"<\/span><span>],<\/span> <span>\"<\/span><span>open_issues<\/span><span>\"<\/span><span>:<\/span> <span>r<\/span><span>[<\/span><span>\"<\/span><span>open_issues_count<\/span><span>\"<\/span><span>],<\/span> <span>\"<\/span><span>language<\/span><span>\"<\/span><span>:<\/span> <span>r<\/span><span>.<\/span><span>get<\/span><span>(<\/span><span>\"<\/span><span>language<\/span><span>\"<\/span><span>),<\/span> <span>\"<\/span><span>description<\/span><span>\"<\/span><span>:<\/span> <span>r<\/span><span>.<\/span><span>get<\/span><span>(<\/span><span>\"<\/span><span>description<\/span><span>\"<\/span><span>),<\/span> <span>}<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The type hint (<code>repo: str<\/code>) 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&#8217;s no schema to keep in sync by hand \u2014 it&#8217;s derived from the same signature Python already type-checks against. Adding a ninth tool is: write a function, put <code>@mcp.tool()<\/code> above it, done. No dispatcher to update, no <code>TextContent<\/code> wrapping, no protocol-layer code touched at all.<\/p>\n<p>Our ADR from when I made this call is blunt about the tradeoff:<\/p>\n<blockquote>\n<p><strong>Decision:<\/strong> Use <code>FastMCP<\/code> from <code>mcp.server.fastmcp<\/code> with <code>@mcp.tool()<\/code> decorators. HTTP calls via stdlib <code>urllib<\/code>.<br \/> <strong>Alternatives Considered:<\/strong> Low-level MCP server \u2192 rejected (unnecessary complexity for this use case).<br \/> <strong>Consequences:<\/strong> <code>mcp[cli]<\/code> is the only dependency. Server is runnable via <code>python server.py<\/code> or <code>mcp dev server.py<\/code>.<\/p>\n<\/blockquote>\n<p>&#8220;Unnecessary complexity for this use case&#8221; is doing a lot of work in that sentence, and I think it&#8217;s the right lens: the low-level API exists because <em>someone<\/em> needs to write MCP client and server libraries, or needs protocol-level control (streaming partial results, custom capability negotiation, non-standard transports). If you&#8217;re exposing 8 REST-API wrappers to an agent, you are not that someone, and the protocol-layer code you&#8217;d write has no relationship to your actual problem.<\/p>\n<h3> <a name=\"where-the-decorator-approach-did-bite-me\" href=\"#where-the-decorator-approach-did-bite-me\"> <\/a> where the decorator approach did bite me <\/h3>\n<p>It&#8217;s not free. Two real snags from actually running this server:<\/p>\n<p><strong>Return type coercion is implicit and easy to get subtly wrong.<\/strong> <code>list_articles<\/code> returns a <code>list[dict]<\/code> built by a list comprehension over the DEV.to API response. FastMCP serializes it fine \u2014 but the first version of that function forgot to cap <code>per_page<\/code>, and a caller passing <code>per_page=1000<\/code> would have silently gotten whatever DEV.to&#8217;s API actually returns for an out-of-range value (turns out it clamps, but I had to check, because FastMCP&#8217;s schema generation from <code>per_page: int = 10<\/code> doesn&#8217;t enforce an upper bound just because the docstring says one exists): <\/p>\n<div>\n<pre><code><span>@mcp.tool<\/span><span>()<\/span> <span>def<\/span> <span>list_articles<\/span><span>(<\/span><span>per_page<\/span><span>:<\/span> <span>int<\/span> <span>=<\/span> <span>10<\/span><span>)<\/span> <span>-&gt;<\/span> <span>list<\/span><span>:<\/span> <span>\"\"\"<\/span><span>List your published DEV.to articles.<\/span><span>\"\"\"<\/span> <span>articles<\/span> <span>=<\/span> <span>_dev<\/span><span>(<\/span><span>f<\/span><span>\"<\/span><span>\/articles\/me?per_page=<\/span><span>{<\/span><span>min<\/span><span>(<\/span><span>per_page<\/span><span>,<\/span> <span>30<\/span><span>)<\/span><span>}<\/span><span>\"<\/span><span>)<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The <code>min(per_page, 30)<\/code> is doing real validation work that the type hint alone doesn&#8217;t give you. The decorator gets you schema generation for free; it does not get you argument <em>validation<\/em> for free. Those are different things and it&#8217;s easy to assume the first implies the second.<\/p>\n<p><strong>Errors inside a tool function become opaque to the agent unless you&#8217;re deliberate about them.<\/strong> If <code>_gh()<\/code> raises <code>urllib.error.HTTPError<\/code> because a repo doesn&#8217;t exist, FastMCP will catch it and turn it into a tool-call error the agent sees \u2014 but the agent sees &#8220;an error occurred,&#8221; not &#8220;404, that repo name is wrong.&#8221; 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.<\/p>\n<p>Neither of those is a reason to go back to hand-rolled JSON-RPC \u2014 they&#8217;re both things you&#8217;d still have to handle yourself at the low level, just with more code around them, not less. But &#8220;the decorator handles it&#8221; is only true for the schema-and-transport layer. Validation and error-message quality are still your job either way.<\/p>\n<h3> <a name=\"the-actual-takeaway\" href=\"#the-actual-takeaway\"> <\/a> the actual takeaway <\/h3>\n<p>If you&#8217;re deciding between the low-level MCP SDK and FastMCP for a server that&#8217;s fundamentally &#8220;wrap some REST APIs as tools,&#8221; the decorator API isn&#8217;t the beginner&#8217;s shortcut that graduates to the &#8220;real&#8221; low-level API once you&#8217;re serious \u2014 it&#8217;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&#8217;t write and didn&#8217;t have to keep in sync \u2014 and the two real gaps I hit (argument validation, error-message shaping) were mine to solve regardless of which API I&#8217;d started from.<\/p>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/enjoy_kumawat\/i-almost-hand-rolled-json-rpc-for-an-mcp-server-eight-tools-later-im-glad-i-didnt-17mj\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>When I built the MCP server for this project \u2014 it combines GitHub and DEV.to into a set of tools an agent can call \u2014 I had a decision to make before writing a single tool: talk to the low-level MCP protocol directly, or use FastMCP&#8216;s decorator API. I&#8217;ve seen a few &#8220;your first MCP [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":3673,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[41],"tags":[],"class_list":["post-3674","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3674","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=3674"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/3674\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/3673"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=3674"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=3674"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=3674"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}