{"slug": "my-mcp-server-s-two-api-helpers-had-zero-except-blocks-every-bad-call-crashed-a", "title": "My MCP Server's Two API Helpers Had Zero except Blocks. Every Bad Call Crashed With a Raw urllib Traceback.", "summary": "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.", "body_md": "I've spent the last few weeks hardening one function in my MCP server's `claude -p`\n\nwrapper. 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`\n\nclauses, all on the same six-line `try`\n\nblock, until `_claude()`\n\nfinally had a clean, consistent failure path: catch everything plausible, return a string prefixed `ERROR:`\n\n, never let a raw exception reach the MCP client.\n\nWhile going back through the rest of `server.py`\n\nto 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()`\n\nfor GitHub, `_dev()`\n\nfor DEV.to — had never gotten it at all. Zero `try`\n\n, zero `except`\n\n, in either one.\n\n``` python\ndef _gh(path, method=\"GET\", data=None):\n    if method != \"GET\":\n        raise ValueError(f\"_gh is read-only — got method={method!r}\")\n    req = urllib.request.Request(f\"https://api.github.com{path}\", method=method)\n    req.add_header(\"Authorization\", f\"token {os.environ['GITHUB_TOKEN']}\")\n    req.add_header(\"Accept\", \"application/vnd.github.v3+json\")\n    with urllib.request.urlopen(req, timeout=30) as r:\n        return json.loads(r.read())\n\ndef _dev(path, method=\"GET\", data=None):\n    req = urllib.request.Request(f\"https://dev.to/api{path}\", method=method)\n    req.add_header(\"api-key\", os.environ[\"DEV_TO_API\"])\n    req.add_header(\"Content-Type\", \"application/json\")\n    req.add_header(\"User-Agent\", _DEV_UA)\n    if data:\n        req.data = json.dumps(data).encode()\n    with urllib.request.urlopen(req, timeout=30) as r:\n        return json.loads(r.read())\n```\n\nEvery one of this server's tools except `generate_commit_message`\n\ngoes through one of these two functions: `list_articles`\n\n, `create_article`\n\n, `update_article`\n\n, `get_article_stats`\n\n, `get_github_profile`\n\n, `list_repos`\n\n, `get_repo_stats`\n\n. Seven tools, one shared blind spot. A wrong `article_id`\n\non `update_article`\n\ngets a 404. An expired `GITHUB_TOKEN`\n\ngets a 401. Too many tags on `create_article`\n\ngets a 422. A burst of calls gets a 429. `urllib.request.urlopen`\n\nturns every one of those into an `HTTPError`\n\nexception, and with no `except`\n\nanywhere in either function, it just propagates straight out.\n\nI 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`\n\nto always raise a 404 and called the tools directly:\n\n```\n=== calling update_article(article_id=999999) ===\nUNCAUGHT HTTPError propagated to caller: <HTTPError 404: 'Not Found'>\n=== calling get_repo_stats('doesnotexist') ===\nUNCAUGHT HTTPError propagated to caller: <HTTPError 404: 'Not Found'>\n```\n\nCompare that to what `_claude()`\n\n's failure path already looks like for the exact same category of problem — an external process/service saying no:\n\n```\n'ERROR: claude -p exited 1: boom'\n```\n\nOne 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.\n\nThe honest answer is that `_claude()`\n\ngot 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`\n\nand `_dev`\n\nnever 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`\n\n, the standalone script this same repo uses for the actual scheduled publishing runs, already wraps the identical `urlopen()`\n\ncall in `except urllib.error.HTTPError as e: sys.exit(f\"HTTP {e.code}: ...\")`\n\n. 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.\n\nSame shape as `_claude()`\n\n's convention, adapted to the failure type that's actually possible here — an `HTTPError`\n\n, not a timeout or missing binary — and raising instead of returning a string, since these are plain Python functions, not MCP tool endpoints themselves:\n\n```\ntry:\n    with urllib.request.urlopen(req, timeout=30) as r:\n        return json.loads(r.read())\nexcept urllib.error.HTTPError as e:\n    raise RuntimeError(f\"GitHub API error {e.code}: {e.read().decode()[:400]}\") from e\n```\n\nSame pattern in `_dev()`\n\n, with the DEV.to-specific message. Reran the stubbed-404 repro against the fixed code:\n\n```\nRuntimeError('DEV.to API error 404: {\"error\":\"not found\"}')\nRuntimeError('GitHub API error 404: {\"message\":\"Not Found\",...}')\n```\n\nStill 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`\n\nrepr with no message payload attached. `e.read()`\n\nonly works once per response, so I made sure it's read exactly there and nowhere else in the call path.\n\nI didn't add retry logic for 429s — `publish_devto.py`\n\n'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`\n\nfor 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`\n\nclause to cover them is a future finding, not something I want to guess the right message format for today.\n\nThe 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()`\n\ngot three fixes because it kept failing loudly. `_gh`\n\n/`_dev`\n\nneeded the same fix and got zero, because nothing had forced the question yet.", "url": "https://wpnews.pro/news/my-mcp-server-s-two-api-helpers-had-zero-except-blocks-every-bad-call-crashed-a", "canonical_source": "https://dev.to/enjoy_kumawat/my-mcp-servers-two-api-helpers-had-zero-except-blocks-every-bad-call-crashed-with-a-raw-urllib-102n", "published_at": "2026-08-01 12:40:14+00:00", "updated_at": "2026-08-01 13:15:12.346458+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["GitHub", "DEV.to", "MCP", "claude -p", "urllib"], "alternates": {"html": "https://wpnews.pro/news/my-mcp-server-s-two-api-helpers-had-zero-except-blocks-every-bad-call-crashed-a", "markdown": "https://wpnews.pro/news/my-mcp-server-s-two-api-helpers-had-zero-except-blocks-every-bad-call-crashed-a.md", "text": "https://wpnews.pro/news/my-mcp-server-s-two-api-helpers-had-zero-except-blocks-every-bad-call-crashed-a.txt", "jsonld": "https://wpnews.pro/news/my-mcp-server-s-two-api-helpers-had-zero-except-blocks-every-bad-call-crashed-a.jsonld"}}