cd /news/developer-tools/my-mcp-server-s-two-api-helpers-had-… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-82982] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

My MCP Server's Two API Helpers Had Zero except Blocks. Every Bad Call Crashed With a Raw urllib Traceback.

A developer discovered that two API helper functions in their MCP server's claude -p wrapper, _gh() for GitHub and _dev() for DEV.to, lacked any exception handling, causing raw urllib tracebacks to propagate to the MCP client on errors like 404, 401, 422, and 429. The developer fixed this by adding consistent error handling to return 'ERROR:' prefixed strings, matching the pattern already used in the _claude() function.

read5 min views1 publishedAug 1, 2026

I've spent the last few weeks hardening one function in my MCP server's claude -p

wrapper. First a timeout fix. Then it turned out the timeout fix didn't cover a non-zero exit code. Then it turned out that fix still didn't cover a missing binary. Three separate posts, three separate except

clauses, all on the same six-line try

block, until _claude()

finally had a clean, consistent failure path: catch everything plausible, return a string prefixed ERROR:

, never let a raw exception reach the MCP client.

While going back through the rest of server.py

to see if that pattern had actually spread anywhere else, I found the two functions that call an external API on almost every tool invocation β€” _gh()

for GitHub, _dev()

for DEV.to β€” had never gotten it at all. Zero try

, zero except

, in either one.

def _gh(path, method="GET", data=None):
    if method != "GET":
        raise ValueError(f"_gh is read-only β€” got method={method!r}")
    req = urllib.request.Request(f"https://api.github.com{path}", method=method)
    req.add_header("Authorization", f"token {os.environ['GITHUB_TOKEN']}")
    req.add_header("Accept", "application/vnd.github.v3+json")
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())

def _dev(path, method="GET", data=None):
    req = urllib.request.Request(f"https://dev.to/api{path}", method=method)
    req.add_header("api-key", os.environ["DEV_TO_API"])
    req.add_header("Content-Type", "application/json")
    req.add_header("User-Agent", _DEV_UA)
    if data:
        req.data = json.dumps(data).encode()
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())

Every one of this server's tools except generate_commit_message

goes through one of these two functions: list_articles

, create_article

, update_article

, get_article_stats

, get_github_profile

, list_repos

, get_repo_stats

. Seven tools, one shared blind spot. A wrong article_id

on update_article

gets a 404. An expired GITHUB_TOKEN

gets a 401. Too many tags on create_article

gets a 422. A burst of calls gets a 429. urllib.request.urlopen

turns every one of those into an HTTPError

exception, and with no except

anywhere in either function, it just propagates straight out.

I checked what that actually looks like on the wire, since "propagates out" undersells it if you haven't watched an MCP client eat a stack trace. Stubbed urlopen

to always raise a 404 and called the tools directly:

=== calling update_article(article_id=999999) ===
UNCAUGHT HTTPError propagated to caller: <HTTPError 404: 'Not Found'>
=== calling get_repo_stats('doesnotexist') ===
UNCAUGHT HTTPError propagated to caller: <HTTPError 404: 'Not Found'>

Compare that to what _claude()

's failure path already looks like for the exact same category of problem β€” an external process/service saying no:

'ERROR: claude -p exited 1: boom'

One is a string a client can read, log, and maybe retry on. The other is an unhandled exception with a Python traceback in it, which β€” depending on how forgiving the MCP client's error surface is β€” an agent calling this tool has to interpret from a stack frame instead of a message.

The honest answer is that _claude()

got audited three separate times because it kept visibly failing in ways I could reproduce by hand β€” run the commit hook, watch it silently produce nothing, dig in. _gh

and _dev

never did that, because in normal use they mostly succeed: valid tokens, real article IDs, no rate limiting. The absence of a crash isn't the same as the absence of a bug; it just means nobody happened to pass a bad ID yet. publish_devto.py

, the standalone script this same repo uses for the actual scheduled publishing runs, already wraps the identical urlopen()

call in except urllib.error.HTTPError as e: sys.exit(f"HTTP {e.code}: ...")

. That script gets exercised twice a day by an unattended job that has to produce a clean failure message if something goes wrong, so it earned the error handling early. The MCP tools, invoked interactively from Claude Desktop, never got the same pressure β€” until a client actually hits one of these paths and the model has to make sense of a traceback instead of a sentence.

Same shape as _claude()

's convention, adapted to the failure type that's actually possible here β€” an HTTPError

, not a timeout or missing binary β€” and raising instead of returning a string, since these are plain Python functions, not MCP tool endpoints themselves:

try:
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())
except urllib.error.HTTPError as e:
    raise RuntimeError(f"GitHub API error {e.code}: {e.read().decode()[:400]}") from e

Same pattern in _dev()

, with the DEV.to-specific message. Reran the stubbed-404 repro against the fixed code:

RuntimeError('DEV.to API error 404: {"error":"not found"}')
RuntimeError('GitHub API error 404: {"message":"Not Found",...}')

Still an exception β€” MCP's own tool-call machinery already turns an unhandled exception into a structured error response back to the client, so I didn't need to reinvent that part. What changed is what's in it: an HTTP status code and the actual response body, truncated to 400 characters, instead of a bare HTTPError

repr with no message payload attached. e.read()

only works once per response, so I made sure it's read exactly there and nowhere else in the call path.

I didn't add retry logic for 429s β€” publish_devto.py

's scheduled-run instructions already handle that at the caller level ("wait 35 seconds and retry"), and duplicating a retry loop inside a helper two callers rely on risks silently doubling wait times if both layers ever retry the same request. I also didn't touch connection-level failures (URLError

for DNS/timeout-before-response) β€” those are a different exception class with a different message shape, and I don't have a live repro for them yet, so extending the except

clause to cover them is a future finding, not something I want to guess the right message format for today.

The bigger lesson, for me, is narrower than "always wrap network calls" β€” I already knew that in the abstract. It's that a hardening pass on one function doesn't tell you anything about its neighbors just because they look similar. _claude()

got three fixes because it kept failing loudly. _gh

/_dev

needed the same fix and got zero, because nothing had forced the question yet.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @github 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/my-mcp-server-s-two-…] indexed:0 read:5min 2026-08-01 Β· β€”