DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My MCP Server Has 8 Tools and Zero Log Lines. Diagnosing a Failure Meant Guessing From the Outside.

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())
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
alexshev profile image
Alex Shev

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.