{"slug": "i-gave-my-mcp-tool-an-error-convention-i-only-taught-it-to-one-of-its-two-paths", "title": "I Gave My MCP Tool an ERROR: Convention. I Only Taught It to One of Its Two Failure Paths.", "summary": "A developer found and fixed a convention mismatch in an MCP tool called `generate_commit_message` that had two different failure formats: one returning an `ERROR:` prefix and another returning a plain string like 'claude -p timed out after 20s'. The inconsistency meant callers checking for the `ERROR:` prefix would treat the timeout message as a valid commit. The fix unified both failure paths to use the `ERROR:` convention.", "body_md": "Two days ago I found and fixed a real gap in `generate_commit_message`\n\n, the MCP tool in `server.py`\n\nthat turns a git diff into a Conventional Commit message via `claude -p`\n\n. Called with an empty diff, it used to hand the whole empty string straight to Claude and return whatever came back — usually a polite sentence explaining there was nothing to commit, typed as a plain `str`\n\n, indistinguishable from a real commit message to anything downstream that just calls the tool and uses the result. I fixed that by adding a guard:\n\n``` php\n@mcp.tool()\ndef generate_commit_message(diff: str) -> str:\n    \"\"\"Generate a Conventional Commits message from a git diff string.\"\"\"\n    if not diff.strip():\n        return \"ERROR: empty diff — nothing to generate a commit message from.\"\n    return _claude(diff, system=(...))\n```\n\n`ERROR:`\n\nas a prefix, so a caller can do `if result.startswith(\"ERROR:\")`\n\nand know not to trust it as a commit message. That felt like the right convention at the time, and it is — as far as it goes. What I didn't do was go check whether it was the *only* convention this function needed, or whether some other failure path already existed with a different shape.\n\nIt did. `generate_commit_message`\n\ndoesn't just call `claude -p`\n\ndirectly — it goes through a shared helper, `_claude()`\n\n, which has its own failure path for a hung subprocess:\n\n``` php\ndef _claude(prompt: str, system: str = None) -> str:\n    full = (system + \"\\n\\n\" + prompt) if system else prompt\n    try:\n        raw = subprocess.check_output([\"claude\", \"-p\", full], text=True, timeout=20).strip()\n    except subprocess.TimeoutExpired:\n        return \"claude -p timed out after 20s\"\n    return \"\\n\".join(\n        l for l in raw.splitlines()\n        if not _STRIP_RE.search(l)\n    ).strip()\n```\n\nThat timeout branch was added a couple of days before my empty-diff fix, in a different pass through this file, aimed at a different bug (the same call had no timeout at all and could hang `git commit`\n\nindefinitely). It does exactly what it says — stops the hang, returns a string explaining what happened — but it was written before the `ERROR:`\n\nconvention existed, and nothing about adding that convention later went back and reconciled the two.\n\nThe result: `generate_commit_message`\n\nhas exactly two ways to fail without raising, and they don't look like each other.\n\n```\n>>> generate_commit_message(\"\")\n'ERROR: empty diff — nothing to generate a commit message from.'\n\n>>> generate_commit_message(some_diff)   # claude -p hangs past 20s\n'claude -p timed out after 20s'\n```\n\nA caller that learned the right lesson from the empty-diff fix — check for the `ERROR:`\n\nprefix before trusting the return value as a commit message — checks it, gets `False`\n\non a timeout, and happily commits the literal sentence `claude -p timed out after 20s`\n\nas if it were a real Conventional Commit subject. The whole point of the empty-diff guard was to give callers one thing to check. The timeout path quietly opts out of that contract, in the same function, written by the same hand, days apart.\n\nI want to be honest about why this happened rather than just present the fix: it's a sequencing bug, not a subtlety I missed on inspection. The timeout fix and the empty-diff fix were two separate audits on two separate days, each solving the specific bug it was looking for and stopping there. Neither run asked \"does this function already have a different failure convention I should match.\" The empty-diff fix in particular added a *format* — a string prefix meant to be load-bearing for callers — without auditing every existing return path in the same function against that format. It's the same shape as writing a validation rule and only applying it going forward instead of backfilling it, except here \"backfilling\" would have cost one word.\n\nThe fix is exactly that one word:\n\n``` php\ndef _claude(prompt: str, system: str = None) -> str:\n    full = (system + \"\\n\\n\" + prompt) if system else prompt\n    try:\n        raw = subprocess.check_output([\"claude\", \"-p\", full], text=True, timeout=20).strip()\n    except subprocess.TimeoutExpired:\n        return \"ERROR: claude -p timed out after 20s\"\n    return \"\\n\".join(\n        l for l in raw.splitlines()\n        if not _STRIP_RE.search(l)\n    ).strip()\n```\n\nNow both failure paths speak the same language, and `.startswith(\"ERROR:\")`\n\nis a real, complete contract instead of one that only covers the failure mode it happened to be written for.\n\nThe more general lesson is about where to look after fixing a bug that introduces a signaling convention. It's tempting to treat \"I added a way for callers to detect this specific failure\" as the whole task and stop once that one path is covered. But a convention that's supposed to mean \"don't trust this string\" is only as good as its coverage — a partial convention is arguably worse than no convention at all, because it teaches callers to trust a check that doesn't actually cover every way the function can lie to them. The question I should have asked two days ago wasn't \"how do I flag an empty diff,\" it was \"how many ways can this function fail without raising, and do they all look the same to whoever's checking.\" `_claude()`\n\nhad two failure paths and I was only looking at one of them.\n\nI didn't find this by staring at `generate_commit_message`\n\nin isolation — I found it by rereading `_claude()`\n\nright underneath it and noticing the two return statements didn't rhyme. That's a cheap check, one function's worth of scrolling, and it would have been just as cheap two days ago when the `ERROR:`\n\nconvention was first introduced. It just wasn't part of that day's task.", "url": "https://wpnews.pro/news/i-gave-my-mcp-tool-an-error-convention-i-only-taught-it-to-one-of-its-two-paths", "canonical_source": "https://dev.to/enjoy_kumawat/i-gave-my-mcp-tool-an-error-convention-i-only-taught-it-to-one-of-its-two-failure-paths-4619", "published_at": "2026-07-26 12:36:34+00:00", "updated_at": "2026-07-26 13:30:39.164494+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["generate_commit_message", "MCP", "Claude"], "alternates": {"html": "https://wpnews.pro/news/i-gave-my-mcp-tool-an-error-convention-i-only-taught-it-to-one-of-its-two-paths", "markdown": "https://wpnews.pro/news/i-gave-my-mcp-tool-an-error-convention-i-only-taught-it-to-one-of-its-two-paths.md", "text": "https://wpnews.pro/news/i-gave-my-mcp-tool-an-error-convention-i-only-taught-it-to-one-of-its-two-paths.txt", "jsonld": "https://wpnews.pro/news/i-gave-my-mcp-tool-an-error-convention-i-only-taught-it-to-one-of-its-two-paths.jsonld"}}