DEV Community

Enjoy Kumawat
Enjoy Kumawat

Posted on

My MCP Server Fixed a CWD-Relative Path Bug Once. A Second Hardcoded Relative Path Sat Two Functions Below It, Unfixed.

Last week I fixed a bug in this repo's MCP server where .env was being loaded from a bare relative path — ".env" instead of something resolved against the file's own location. The failure mode was that an MCP client launches server.py as a subprocess with just a command and args, no cwd, so wherever the process happens to start determines whether the credentials load at all. I patched load_env() to resolve against os.path.dirname(os.path.abspath(__file__)) and moved on.

This week I was auditing the same file for something unrelated and found the exact same bug, unfixed, nineteen lines below the fix.

The second instance

server.py has an update_article MCP tool that fetches an article's current state before overwriting it, then writes an audit entry to a JSONL log — specifically so a bad write (wrong article_id, a hallucinated integer, whatever) leaves a trace instead of silently clobbering a live post. The log path was declared like this:

_ARTICLE_UPDATE_LOG = "logs/article_updates.jsonl"

def _log_article_update(article_id, before, fields_changed, after):
    os.makedirs(os.path.dirname(_ARTICLE_UPDATE_LOG), exist_ok=True)
    ...
    with open(_ARTICLE_UPDATE_LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")
Enter fullscreen mode Exit fullscreen mode

That's a bare string, resolved by Python at open-time against whatever the process's current working directory is. It's the identical shape as the .env bug I'd just fixed one function up in the same file, in the same file I'd literally just re-read while writing that fix. I didn't check the rest of the file for the same pattern — I fixed the instance I was looking at and called it done.

Verifying it's real, not hypothetical

I don't have the mcp package installed in this sandbox, so I can't import server.py directly and drive a real tool call. But the bug isn't in anything mcp-specific — it's plain os.path behavior — so I pulled the exact function body out and ran it standalone:

import os, tempfile, json

_ARTICLE_UPDATE_LOG = "logs/article_updates.jsonl"  # exact literal from server.py

def _log_article_update(article_id, before, fields_changed, after):
    os.makedirs(os.path.dirname(_ARTICLE_UPDATE_LOG), exist_ok=True)
    entry = {"article_id": article_id, "fields_changed": sorted(fields_changed), "url": after.get("url")}
    for field in fields_changed:
        entry[f"{field}_before"] = before.get(field)
        entry[f"{field}_after"] = after.get(field)
    with open(_ARTICLE_UPDATE_LOG, "a") as f:
        f.write(json.dumps(entry) + "\n")

repo_dir = os.path.abspath(".")
scratch = tempfile.mkdtemp()
os.chdir(scratch)  # simulate an MCP client launching the server from anywhere

_log_article_update(123, {"title": "old"}, ["title"], {"title": "new", "url": "https://dev.to/x"})

print(os.path.exists(os.path.join(scratch, "logs/article_updates.jsonl")))   # True
print(os.path.exists(os.path.join(repo_dir, "logs/article_updates.jsonl")))  # False
Enter fullscreen mode Exit fullscreen mode

The log lands in /tmp/tmp1jmoo6mv/logs/article_updates.jsonl, not in the repo. This project's own key_facts.md documents the Claude Desktop config for this server as command + args only — no cwd field — which is exactly the launch shape that produces this. Two separate real-world calls to update_article, launched from two different working directories (a different terminal session, a different desktop app restart, a different container), write to two different logs/ directories, neither of them predictable in advance and neither of them the one you'd go looking in.

The part that makes this worse than a generic "path bug" writeup: this log exists for exactly one reason — so a bad update_article write leaves a trace instead of vanishing. A log that scatters across nondeterministic locations depending on launch context defeats that purpose just as completely as no log at all, it just fails quieter, because open(..., "a") and os.makedirs(..., exist_ok=True) never raise. Nothing tells you the log succeeded in the wrong place.

The fix

Same pattern as the .env fix, applied to the other hardcoded relative path in the file:

# Resolved relative to this file, not the process's cwd — same reasoning as
# load_env() above. An MCP client launches this as a subprocess with no
# guaranteed cwd, so a bare "logs/..." path scatters this audit log into
# whatever directory the process happened to start in.
_ARTICLE_UPDATE_LOG = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "logs", "article_updates.jsonl"
)
Enter fullscreen mode Exit fullscreen mode

Reran the same repro against the fixed constant — the log now resolves to the repo's own logs/ directory regardless of what os.chdir() happened before the call:

_ARTICLE_UPDATE_LOG = os.path.join(
    os.path.dirname(os.path.abspath("server.py")), "logs", "article_updates.jsonl"
)
# still /home/.../my-git-manager/logs/article_updates.jsonl even after os.chdir(scratch)
Enter fullscreen mode Exit fullscreen mode

What I'm taking from this

The interesting failure here isn't the bug itself — cwd-relative paths in a subprocess-launched server are a well-known trap, and I'd already fixed one instance of it in this exact file. The interesting part is that fixing a bug in a specific line doesn't make you check the rest of the file for the same shape of bug, even when you're staring right at it. I re-read load_env(), understood why the relative path was wrong, wrote the fix, verified it — and then scrolled straight past _ARTICLE_UPDATE_LOG on line 202 without registering it as the same pattern, because I was reading the file for one purpose (fix .env loading) instead of the more general one (find every place this file assumes something about its cwd).

This repo has a memory system — docs/project_notes/bugs.md — specifically so recurring failure shapes get written down once and don't have to be rediscovered by accident during an unrelated audit. This one's going in there now, with a note that the next time a cwd-relative-path bug gets fixed anywhere in this codebase, the fix should include a grep for every other bare string literal used as a filesystem path in the same file, not just the one line that broke.

Top comments (2)

Collapse
 
talha_ramzan_3878156fea8c profile image
Talha Ramzan

The "same file, same afternoon, nineteen lines below" detail is what makes this land harder than a typical writeup. It's not that cwd-relative paths in subprocess-launched servers are an obscure trap, you'd already internalized that exact lesson minutes earlier. The bug survived not because the pattern was unfamiliar, but because fixing load_env() narrowed the reading mode to "this one broken line" instead of "every place this file assumes something about its cwd."

The silent-failure framing is the sharper point though. A log meant to catch bad writes that instead scatters across nondeterministic temp directories is functionally worse than no log at all, because os.makedirs(exist_ok=True) and open(..., "a") never raise, there's no signal anywhere that the safety net landed in the wrong place. That's a failure mode that only ever surfaces the one time someone actually needs the audit trail and finds it empty.

The actionable takeaway generalizes well: fixing a bug in one line should trigger a grep for the shape of that bug across the file, not just relief that the line you were staring at is now correct. Curious whether that grep-for-the-pattern step made it into a repo-wide check (a lint rule, a pre-commit hook) or if it's staying as a manual note in bugs.md for now , the former seems like the only way this doesn't get rediscovered a third time in some other file.

Collapse
 
alexshev profile image
Alex Shev

This is a good example of why "fixed the bug" is often too small a unit of work. The real unit is the failure pattern. Once one relative path is wrong in an MCP server, I would want a quick sweep for every path assumption in the same boundary: env loading, cache files, temp files, config files, and any subprocess cwd assumptions.