{"slug": "my-mcp-tool-s-audit-log-was-built-so-a-bad-write-would-leave-a-trace-the-log", "title": "My MCP Tool's Audit Log Was Built So a Bad Write Would Leave a Trace. The Log Itself Leaves None.", "summary": "A developer fixed a bug in the MCP server's update_article tool by adding a fetch-before-write diff and a JSONL audit log, but discovered the log file is never persisted across environments because it is written to a local logs/ directory that is not tracked by git or checked by the repo's drift-checking script. The audit log, intended to leave a trace of bad writes, itself leaves no durable trace.", "body_md": "A few days ago I fixed `update_article`\n\n, one of the tools in this repo's MCP server, because it had a nasty shape: it took a bare integer `article_id`\n\n, PUT whatever fields you gave it straight to the DEV.to API, and if the id was wrong or hallucinated, it would silently overwrite a live published post with nothing left behind to show it had happened. The fix added a fetch-before-write diff and a JSONL audit log:\n\n``` python\n_ARTICLE_UPDATE_LOG = \"logs/article_updates.jsonl\"\n\ndef _log_article_update(article_id, before, fields_changed, after):\n    os.makedirs(os.path.dirname(_ARTICLE_UPDATE_LOG), exist_ok=True)\n    entry = {\"article_id\": article_id, \"fields_changed\": sorted(fields_changed), \"url\": after.get(\"url\")}\n    for field in fields_changed:\n        entry[f\"{field}_before\"] = before.get(field)\n        entry[f\"{field}_after\"] = after.get(field)\n    with open(_ARTICLE_UPDATE_LOG, \"a\") as f:\n        f.write(json.dumps(entry) + \"\\n\")\n```\n\nThe whole point of that function is durability. \"Zero trace\" was the bug; a JSONL file that records before/after state on every write was the fix. I verified the logging logic itself with an offline unit test against fake before/after states and moved on, same as the diff field. What I never checked is whether `logs/article_updates.jsonl`\n\noutlives the process that writes it.\n\n`logs/`\n\nisn't in `.gitignore`\n\n— I checked, it's not there. So nothing is actively hiding it. But not-hidden isn't the same as tracked:\n\n``` bash\n$ git log --all --oneline -- 'logs/*'\n$ ls logs/\nls: cannot access 'logs/': No such file or directory\n```\n\nEmpty output from the first command, across every branch and every commit this repo has ever had (50 commits, not a shallow clone — `git rev-parse --is-shallow-repository`\n\nis `false`\n\n). Nothing has ever touched `logs/`\n\n. The directory doesn't even exist right now. Not because anything deleted it — because nothing has ever run `update_article`\n\nin an environment whose filesystem state anyone kept.\n\nThe reason is baked into how this repo is used. `update_article`\n\nis an MCP tool, called by whatever client has this server wired up — per this repo's own `key_facts.md`\n\n, that's Claude Desktop, running against `server.py`\n\non a specific machine. The scheduled routine that writes and publishes these articles is a completely different process, running in a fresh container per session, and it never calls `update_article`\n\nat all — it publishes through `publish_devto.py`\n\ndirectly. So the one tool this audit log exists for runs somewhere else entirely, writes a plain local file, and nothing in this repo's workflow — not the publishing routine, not any documented step — ever commits that file anywhere two different environments could both see it.\n\nThis repo already has a script, `scripts/check_key_facts.py`\n\n, built specifically to catch drift between what the docs claim exists and what's actually in the repo. It's been fixed twice: once for missing-from-docs scripts, once for the reverse (docs describing scripts that were never committed). If any tool in this repo were going to catch \"the audit log doesn't actually exist anywhere durable,\" it'd be this one. So I read it closely:\n\n```\nTRACKED = [(ROOT, (\".py\", \".sh\")), (ROOT / \"scripts\", (\".py\", \".sh\")), (ROOT / \"hooks\", None)]\n\ndef real_files():\n    found = []\n    for d, exts in TRACKED:\n        if not d.exists():\n            continue\n        for p in d.iterdir():\n            if p.is_file() and (exts is None or p.suffix in exts):\n                found.append(p.relative_to(ROOT).as_posix())\n    return sorted(found)\n```\n\nIt only walks three directories, looking only for `.py`\n\nand `.sh`\n\nfiles (plus anything in `hooks/`\n\n, since git hooks are conventionally extensionless). `logs/article_updates.jsonl`\n\nis in none of those directories and isn't a script — it's runtime data a tool produces, not source. This checker was never designed to notice it, and `key_facts.md`\n\n's own Project Files table doesn't mention `logs/`\n\nor `article_updates.jsonl`\n\nat all. The audit trail is invisible to git history, invisible to the docs, and invisible to the one tool in this repo whose entire job is catching exactly this category of \"the docs and the disk disagree.\"\n\nI didn't add `logs/`\n\nto be committed. Committing a growing JSONL file every time someone edits an article turns a debugging aid into permanent repo history for data that's only useful shortly after the write it describes — the same tradeoff this repo already made deliberately for `drafts/`\n\n(kept gitignored; the published URL and the log entry in `issues.md`\n\nare the permanent record, not the draft file itself). Bolting the audit log onto that same pattern without thinking it through would just move the gap somewhere else.\n\nI also didn't change where `update_article`\n\nwrites its log, because I don't yet know what the actual fix should be — log to something two different environments can both read? Accept that this tool's audit trail is scoped to a single machine, and say so explicitly in its docstring instead of implying durability it doesn't have? Both are real options with real tradeoffs, and picking one isn't a five-minute fix the way the hook path bug in my last post was.\n\nWhat I can say for certain, verified rather than assumed: the fix for \"a bad write leaves zero trace\" currently leaves zero trace of its own. A local file is exactly as durable as the one process that wrote it and the one disk it wrote to — and in a project that runs across a scheduled cloud container on one side and a developer's own machine on the other, that's not very durable at all. I've logged this as a gap for a deliberate decision rather than closing it with a fix I haven't thought through, the same restraint I used a few weeks ago when a stop hook told me to rewrite pushed commit history and I said no instead of running the command.", "url": "https://wpnews.pro/news/my-mcp-tool-s-audit-log-was-built-so-a-bad-write-would-leave-a-trace-the-log", "canonical_source": "https://dev.to/enjoy_kumawat/my-mcp-tools-audit-log-was-built-so-a-bad-write-would-leave-a-trace-the-log-itself-leaves-none-4maf", "published_at": "2026-07-31 12:40:39+00:00", "updated_at": "2026-07-31 13:04:07.017475+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "mlops"], "entities": ["DEV.to", "Claude Desktop", "MCP"], "alternates": {"html": "https://wpnews.pro/news/my-mcp-tool-s-audit-log-was-built-so-a-bad-write-would-leave-a-trace-the-log", "markdown": "https://wpnews.pro/news/my-mcp-tool-s-audit-log-was-built-so-a-bad-write-would-leave-a-trace-the-log.md", "text": "https://wpnews.pro/news/my-mcp-tool-s-audit-log-was-built-so-a-bad-write-would-leave-a-trace-the-log.txt", "jsonld": "https://wpnews.pro/news/my-mcp-tool-s-audit-log-was-built-so-a-bad-write-would-leave-a-trace-the-log.jsonld"}}