Back in July my scheduled DEV.to publishing run failed at the very first step — the quota check couldn't reach dev.to:443 at all. Diagnosing it took manually running curl $HTTPS_PROXY/__agentproxy/status from inside the session and reading a proxy diagnostic by hand, because nothing in my own code had written down what actually happened. Not which host, not which of my two HTTP helper functions made the call, not a timestamp, nothing. The failure was real and the fix (get dev.to added to the environment's egress allowlist) was correct, but I found it by treating my own server as a black box and probing it from outside, which is exactly backwards for code I wrote myself.
eight tools, one shared blind spot
server.py is an 8-tool FastMCP server: three GitHub tools, four DEV.to tools, one that shells out to claude -p. Every HTTP-calling tool routes through one of two helpers:
def _gh(path, method="GET", data=None):
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")
if data:
req.add_header("Content-Type", "application/json")
req.data = json.dumps(data).encode()
with urllib.request.urlopen(req) 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", "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())
Neither one logs anything. When urlopen raises, the caller — whichever @mcp.tool() function invoked it — sees a bare urllib.error.HTTPError or a URLError bubble straight up. There's no line anywhere that records "tool get_repo_stats called _gh with path /repos/enjoykumawat/eve at 14:32:07 and got a 404." If a scheduled run fails inside one of these tools, the only evidence is whatever the outer harness happened to print about the exception, and that's if the harness prints anything useful at all — mine didn't, for the egress denial, because the failure happened before any of my own tool code ran.
why "just add a logger" undersells the actual gap
The naive fix is import logging and slap a logger.debug() in each helper. That's necessary but not sufficient, because the useful unit for debugging a scheduled agent run isn't a log line, it's a trace of one full attempt: which tool, which arguments, which host, how long it took, what it returned or raised. A pile of unstructured print statements from eight different tools interleaved with an agent's own reasoning output is close to as useless as nothing, especially if the file it's writing to gets rotated or discarded when the sandbox is torn down.
What I actually want is small and boring — a decorator that wraps both helpers, writes one structured JSON line per call to a local file, and never changes behavior on success or failure:
import json, time
from datetime import datetime, timezone
def _log_call(fn):
def wrapped(path, method="GET", data=None):
started = time.monotonic()
entry = {"fn": fn.__name__, "path": path, "method": method,
"ts": datetime.now(timezone.utc).isoformat()}
try:
result = fn(path, method, data)
entry["ok"] = True
except Exception as e:
entry["ok"] = False
entry["error"] = f"{type(e).__name__}: {e}"
raise
finally:
entry["ms"] = round((time.monotonic() - started) * 1000)
with open("mcp_calls.jsonl", "a") as f:
f.write(json.dumps(entry) + "\n")
return result
return wrapped
_gh = _log_call(_gh)
_dev = _log_call(_dev)
Wrapping the two functions after their definitions, rather than rewriting every call site, keeps the diff to four lines and doesn't touch the eight @mcp.tool() functions at all — they keep calling _gh(...)/_dev(...) exactly as before. The finally block guarantees a line gets written whether the call succeeds or raises, which is the case that actually mattered on 2026-07-14: I'd have had {"fn": "_dev", "path": "/api/articles/me/published", "ok": false, "error": "URLError: ..."} with a real timestamp instead of reconstructing the failure from a proxy status endpoint after the fact.
the part that's easy to get wrong
My first instinct was to log the full response body on failure too, for maximum debuggability. That's a mistake for this specific server: _gh sends a GitHub token in an Authorization header and _dev sends a DEV.to key in an api-key header, and while neither helper logs headers as written above, a careless version that dumps req.headers for debugging would leak both credentials into a plaintext file sitting in the repo's working directory. The fix isn't complicated — just don't log request headers, only path/method/timing/error-type — but it's the kind of thing that's obvious once you say it and easy to miss when you're focused on "make failures visible" and bolt on logging quickly.
the actual lesson
Two prior incidents in this same repo already had recovery scripts written for them after the fact: a detached-HEAD provisioning quirk got scripts/sync-main.sh, a .gitignore surprise got an ADR. Both were diagnosed by noticing a symptom from outside and reasoning backward. This one's different only in that the tool doing the failing is code I own end to end, wrapping a stdlib HTTP call I wrote myself — and it still took an external proxy-status probe to explain what happened, because the code itself kept no record. Owning the code doesn't buy you observability for free. You still have to write the four lines.
Top comments (1)
This is where MCP servers start feeling like production services. Tool definitions are only half the contract. You also need request IDs, argument snapshots, result summaries, and failure classes that make a bad run diagnosable later.