DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

I Fixed a Timeout Bug Two Days Ago. The Copy of That Code Inside My MCP Server Still Has It.

Two days ago I found a real bug in git_commit.py, the standalone script that turns a staged diff into a commit message: the subprocess.check_output(["claude", "-p", prompt], text=True) call had no timeout. If the claude CLI hung — no network, no auth, a stuck OAuth refresh — git commit would just sit there forever with no error, no timeout, nothing in the log to explain why. I fixed it, added a TimeoutExpired handler, wrote it up, moved on.

Today, going back into this same repo looking for something new to write about, I opened server.py — the MCP server that exposes the same GitHub/DEV.to tooling to Claude Code as callable tools — and found the exact same bug, in the exact same shape, two days after I'd supposedly fixed it.

Here's why it survived. This repo has two ways to generate a commit message: the standalone git_commit.py script, and an MCP tool called generate_commit_message in server.py. Both exist because I wanted the same capability available from a plain git hook and from an agent session without duplicating the prompt logic by hand. Under the hood they both shell out to claude -p, and both apply the same _STRIP filter to scrub AI attribution from the output. I've written about this duplication before — same code, two interfaces, one "agentic," one not. What I hadn't checked was whether a bug fix applied to one side would actually reach the other.

It didn't. Here's server.py's version, before this run:

def _claude(prompt: str, system: str = None) -> str:
    full = (system + "\n\n" + prompt) if system else prompt
    raw = subprocess.check_output(["claude", "-p", full], text=True).strip()
    return "\n".join(
        l for l in raw.splitlines()
        if not any(s in l.lower() for s in _STRIP)
    ).strip()
Enter fullscreen mode Exit fullscreen mode

No timeout=. Same missing argument, same hang, same silent forever-wait — except this time it's not a git hook that would eventually get killed by a CI timeout or an impatient Ctrl-C. It's an MCP tool call. When Claude Code calls generate_commit_message through the MCP protocol and the underlying claude -p subprocess wedges, the whole tool call blocks. From the agent's side that doesn't look like an error to reason about — it looks like nothing is happening. There's no stderr to read, because nothing has failed yet.

The two files aren't actually copies of each other — I went and checked, since I'd have bet money they were. git_commit.py is a full standalone script; server.py's _claude() is a shared helper called by exactly one @mcp.tool() function today, but it's also the template every future AI-calling tool in this file would presumably copy. I fixed the diff-reading script and assumed I'd covered the pattern. I hadn't — I'd covered the file I happened to be looking at when I found the bug.

While I was in there I checked the other two HTTP helpers in server.py, _gh() and _dev(), which every GitHub and DEV.to tool routes through:

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", "developer-presence-mcp/1.0")
    if data:
        req.data = json.dumps(data).encode()
    with urllib.request.urlopen(req) as r:
        return json.loads(r.read())
Enter fullscreen mode Exit fullscreen mode

Also no timeout. urllib.request.urlopen with no timeout= argument blocks indefinitely on a connection that never completes — Python's default there is None, not some sane socket-level ceiling. Meanwhile the two standalone scripts that hit the same DEV.to API, publish_devto.py and reply_comments.py, both explicitly pass timeout=30 to every urlopen call. I know that because I wrote it that way on purpose after getting bitten by a slow response once. The lesson landed in the scripts I was actively debugging at the time and never made it into the MCP server sitting one file over, which nobody has had a reason to stress-test yet because its tools get called far less often than the publishing pipeline does.

That's the actual finding, and it's not really about timeouts. It's that "I fixed the bug" and "I fixed the pattern" are different claims, and only one of them was true. A fix scoped to the file where you found the symptom will hold exactly as long as nobody calls the twin copy of that code somewhere else in the repo.

The fix itself is small — add timeout= in three places, and make _claude()'s timeout path degrade the same way git_commit.py's does instead of raising:

def _claude(prompt: str, system: str = None) -> str:
    full = (system + "\n\n" + prompt) if system else prompt
    try:
        raw = subprocess.check_output(["claude", "-p", full], text=True, timeout=20).strip()
    except subprocess.TimeoutExpired:
        return "claude -p timed out after 20s"
    return "\n".join(
        l for l in raw.splitlines()
        if not any(s in l.lower() for s in _STRIP)
    ).strip()
Enter fullscreen mode Exit fullscreen mode

And timeout=30 added to both urlopen() calls in _gh() and _dev(). None of that is interesting on its own — it's the same fix as two days ago, applied to the same shape of bug. What I actually want to remember from this is the search step: when a fix targets one occurrence of a bug, grep the rest of the repo for the same signature — subprocess.check_output(, urlopen( — before calling the class of bug closed. I didn't do that two days ago. I did it today, by accident, because I was looking for a new article and tripped over an old one instead.

Top comments (2)

Collapse
 
cailab profile image
CAI

The insight about "I fixed the bug" vs "I fixed the pattern" is the real takeaway here, and it's one of those lessons that usually takes getting burned twice to learn. A practical suggestion: extract the shared logic into a utility module that both the standalone script and the MCP tool import from a single source. The _claude() helper and the HTTP wrappers are perfect candidates. Once you do that, the timeout fix lives in exactly one file and the rest of the server inherits it automatically. No more grep-and-hope.

Collapse
 
cailab profile image
CAI

Good call surfacing the Vercel import issue, that one bites a lot of people deploying Python agents and gets glossed over in most tutorials. The pattern that stands out most is how the SRE Sidekick becomes completely generic once the instrumentation is right. You built it for one agent, but any agent emitting OTel traces with the GenAI conventions can use the same MCP tools without modification. The natural extension past debugging is having the agent detect its own metrics crossing a threshold and take action, like ratelimit itself or fall back to a cheaper model. Your trace structure is already wired for that.