Give your MCP server a write tool, then delete the model from it
Shipped
v0.5.0 pulled the model out of my fitness app’s web server, in three gated phases. Phase 1 added a write tool and a validate-then-atomically-write gate, so a brief composed anywhere could be persisted through the same integrity path the server used to run internally. Phase 2 turned the frontend into a passive viewer of whatever got written, dropping the embedded chat panels. Phase 3 deleted the server-side chat loop and the brief-generation endpoints outright, so the process that serves my Garmin data now runs no inference at all. Before any of that shipped, an adversarial quality-gate and a live siege against the running container found a Fatal: a “read-only” SQL tool that wasn’t.
The reusable part isn’t the fitness app. It’s the general move Anthropic frames as workflows versus agents: keep predictable, testable work in predefined code paths, and reserve the model for the parts that genuinely need model-driven judgment (Anthropic: Building effective agents). An MCP server only has to run a model if it’s the one doing the synthesis. Give it a genuine write tool alongside its read tools, and synthesis can happen anywhere an MCP client runs, Claude Desktop, Claude Code, whatever you already use, while the server goes back to being a thin, testable layer of reads and writes. Here’s how to build that shape, plus the exact way a read-only guard fails if you don’t enforce it at the right layer.
Setup: a server with one read tool and one write tool
Start with the smallest version of the shape: one tool that reads, one tool that writes. The Python SDK’s FastMCP class turns a plain function into an MCP tool with a decorator; define it, and any MCP client that connects can discover it and call it (MCP: Build an MCP server).
python3 -m venv .venv && source .venv/bin/activate
pip install "mcp[cli]" pydantic
# server.py
import json, os, tempfile
from datetime import date
from pathlib import Path
from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, ValidationError
mcp = FastMCP("journal")
DATA_DIR = Path(__file__).parent / "data"
DATA_DIR.mkdir(exist_ok=True)
class Entry(BaseModel):
headline: str
body: str
@mcp.tool()
def get_today() -> dict:
"""Read today's raw numbers. This is the only data the write tool trusts."""
return {"date": str(date.today()), "steps": 8123, "resting_hr": 52}
@mcp.tool()
def save_entry(headline: str, body: str) -> dict:
"""Validate and atomically persist a journal entry for today."""
try:
entry = Entry(headline=headline, body=body)
except ValidationError as e:
return {"saved": False, "error": str(e)}
path = DATA_DIR / f"{date.today()}.json"
fd, tmp_path = tempfile.mkstemp(dir=DATA_DIR, suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
json.dump(entry.model_dump(), f)
os.replace(tmp_path, path) # one filesystem op, no half-written file
finally:
if os.path.exists(tmp_path):
os.remove(tmp_path)
return {"saved": True, "path": str(path)}
if __name__ == "__main__":
mcp.run()
get_today is read-only by construction: it returns numbers and touches nothing. save_entry is the one that matters, because the moment a client can call it, that client can also be a model, and a model’s output is exactly the kind of thing you don’t want landing on disk unchecked.
Build: validate before you trust it, then write atomically
save_entry has two jobs, and both exist because the caller might not be a careful human. First, the Entry model rejects a malformed call before it touches the filesystem at all; a missing field or wrong type comes back as a clean error instead of a half-formed row on disk. Second, the write itself goes through a temp file and os.replace, which swaps the file in a single filesystem operation, so a reader can never observe a partially written file mid-write.
This is the general shape of what the real write gate in local-fitness does: validate against a schema, then one atomic write, shared by every caller that can produce a saved brief, whether that’s a scheduled job, the MCP tool, or a manual test run. One gate means there’s exactly one place a malformed or half-written brief could ever come from, and that place already refuses both.
Use it: point a real client at it, watch it read then write
The whole point is that you don’t write the client. Claude Desktop and Claude Code both already know how to be one: point either at the server and it lists the tools, decides when to call them, and calls them, the same loop it runs for every MCP server. On macOS, Claude Desktop reads its server list from ~/Library/Application Support/Claude/claude_desktop_config.json (MCP: Connect to local MCP servers):
{
"mcpServers": {
"journal": {
"command": "python3",
"args": ["/absolute/path/to/server.py"]
}
}
}
Restart the client, and asking it to “check today’s numbers and log a journal entry” drives exactly the two tools above: it calls get_today, decides what the entry should say, and calls save_entry. No code you wrote runs that loop.
To see the same round trip without opening a chat client, this is short enough to script directly with the MCP client library, which is also a fine way to sanity-check a server before you ever point a real agent at it:
# client.py
import asyncio, json
from pathlib import Path
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
SERVER = StdioServerParameters(command="python3", args=[str(Path("server.py").resolve())])
async def main():
async with stdio_client(SERVER) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("tools:", [t.name for t in tools.tools])
snapshot = await session.call_tool("get_today", {})
data = json.loads(snapshot.content[0].text)
print("get_today ->", snapshot.content[0].text)
result = await session.call_tool("save_entry", {
"headline": f"{data['steps']} steps, resting HR {data['resting_hr']}",
"body": "Synthesized from get_today by the client, not the server.",
})
print("save_entry ->", result.content[0].text)
asyncio.run(main())
Running it against the server above produces:
tools: ['get_today', 'save_entry']
get_today -> {
"date": "2026-07-11",
"steps": 8123,
"resting_hr": 52
}
save_entry -> {
"saved": true,
"path": "/path/to/demo/data/2026-07-11.json"
}
The synthesis, deciding what the headline should say, happened in the client, not in server.py. server.py never imports a model SDK and never will, because it doesn’t need to; it only needs to keep telling the truth about what it read and refuse to write anything that doesn’t validate.
A read-only guard has to be enforced where writes actually happen
The part worth slowing down for is the one the siege caught. Before this release, the analogous ad-hoc query tool in local-fitness, run_sql, enforced “read-only” the way it’s tempting to: check that the query starts with SELECT or WITH, then scan the rest for forbidden keywords like delete , padded with spaces on both sides so a bare “delete” inside a word like “deleted_at” wouldn’t false-positive.
That padding is exactly what a real DELETE slipped past. A statement can put whitespace other than a plain space between a keyword and what follows, and SQL doesn’t care:
WITH a AS (SELECT 1)
delete
from workouts
delete here is followed by a newline, not a space, so the padded string " with a as (select 1)\ndelete\nfrom workouts " never contains the literal substring "delete ". The prefix check passes because the query starts with WITH. The keyword check passes because the denylist was written assuming keywords are always space-separated. The query runs. I can reproduce both halves directly:
def looks_readonly(q: str) -> bool:
lowered = q.lower()
if not (lowered.startswith("select") or lowered.startswith("with")):
return False
forbidden = ("insert ", "update ", "delete ", "drop ", "alter ")
padded = f" {lowered} "
return not any(kw in padded for kw in forbidden)
evasive = "WITH a AS (SELECT 1)\ndelete\nfrom workouts"
print(looks_readonly(evasive)) # denylist should say False, but watch:
True
Run that same string through a normal, writable sqlite3.connect() and it deletes every row, denylist or not. OWASP names this failure mode directly: a denylist enumerates the bad input the author thought of, and it’s reliably evadable through exactly the casing, whitespace, and phrasing nobody anticipated (OWASP: Input Validation Cheat Sheet). The fix wasn’t a smarter parser; it was moving the guarantee to a layer that doesn’t parse the string at all. SQLite’s URI filenames support a mode=ro parameter that opens the database read-only at the connection level (SQLite: URI Filenames):
import sqlite3
conn = sqlite3.connect("file:journal.db?mode=ro", uri=True)
conn.execute(evasive)
sqlite3.OperationalError: attempt to write a readonly database
Same query, same phrasing trick, and it fails every time, because the engine isn’t asking whether the text looks like a write; it’s refusing to write, full stop. The keyword denylist is still worth keeping as a cheap first filter that returns a clear message instead of a raw database error, but it is defense-in-depth now, not the actual gate. If a tool’s whole job is “read-only,” don’t spend effort teaching a string matcher what a write looks like; open the connection so a write is structurally impossible and let a badly phrased query fail exactly the way a well-phrased one would.
Gotchas
- A denylist keyed on spaces assumes a lot about SQL formatting. Tabs, newlines, and comments are all valid separators SQL accepts and a naive keyword scan doesn’t. If you must keep a string-level check, treat it as a cheap early return for a nicer error message, never as the actual security boundary.
- A read-only connection isn’t automatically the one you’re using. The fix only holds if every call site that’s supposed to be read-only actually opens the read-only connection; a second code path that reaches for the regular writable one silently reopens the hole. Worth a one-line regression test that asserts the ad-hoc query tool’s connection came from the read-only helper, not just that a denylist exists.
- Validate before you write, not after. In the write-tool example above, the schema check happens before the temp file is even created. Writing first and validating after just moves the same bad data from memory onto disk with extra steps.
Sources
- Anthropic: Building effective agents — the workflows-versus-agents distinction: predefined code paths for predictable work, model-driven flexibility only where it’s actually needed.
- MCP: Build an MCP server — the
FastMCP+@mcp.tool()pattern for defining tools a client can discover and call. - MCP: Connect to local MCP servers — the
claude_desktop_config.jsonmcpServersformat for pointing a real client at a local server. - OWASP: Input Validation Cheat Sheet — denylists enumerate known-bad input and are reliably evadable; enforce at the layer that actually owns the guarantee.
- SQLite: URI Filenames — the
mode=roquery parameter that opens a connection read-only at the engine.
Changelog
- feat: agent-first Phase 1 — briefs.py write gate, save_brief tool, brief prompt (#25) (590f6bb)
- feat: agent-first Phase 2 — frontend becomes a viewer of agent-written output (#25) (1980ea6)
- feat: agent-first Phase 3 — retire the server-side Claude loops (#25) (8a500ce)
- fix(security): harden run_sql + MCP tool inputs (quality-gate findings, #25) (35d5230)
- fix: pin pnpm 10.33.0 + supportedArchitectures so the container fetches the rolldown linux binding (2847337)