Let an agent read your bank statements without handing it your PII
Shipped
local-budget v0.1.0 is a local-first, agent-first spending tool. It imports your bank exports into a local SQLite database, then exposes a set of read tools over a stdio MCP server so a Claude Code session can answer “how am I doing this month” without any of your data leaving the machine. The server runs no inference of its own; it serves deterministic data and lets the client model do the talking.
The interesting part is not the budgeting. It is the shape of the server: how you give an agent useful tools over private data while making it structurally impossible for the model to read the sensitive columns. This guide builds that server from scratch. You can point it at any SQLite database you would not want pasted into a chat window.
What you need first
- Python 3.11+ and the official MCP SDK:
pip install mcp. - A local SQLite database. This guide assumes a
transactionstable with a few clean columns the agent may see (posted_date,amount_cents,category,merchant_norm) and a few it must not (payee,memo,raw_ofx,acct_hash) where the raw statement text and account numbers live. - A client that speaks MCP over stdio (Claude Code, or any MCP client).
The whole design rests on one rule: the model is untrusted with your PII, so the server enforces what it can read, not a prompt asking it nicely.
Step 1: Define tools as data, not decorators
Make each tool a plain record: a name, a description the model reads, a JSON Schema for its
input, and an async handler. One registry is the single source of truth for both tools/list
and tools/call, so the two can never drift.
from dataclasses import dataclass
from typing import Awaitable, Callable
@dataclass(frozen=True)
class ToolSpec:
name: str
description: str
input_schema: dict
handler: Callable[[dict], Awaitable[dict]]
def obj(props: dict | None = None, required: list[str] | None = None) -> dict:
"""A real JSON-Schema object. The MCP wire format needs this exact shape."""
return {"type": "object", "properties": props or {}, "required": required or []}
A handler returns a dict with two keys: rendered (human-readable markdown) and data
(the machine payload). We will use both in Step 3.
async def get_month_summary(args: dict) -> dict:
month = args.get("month") or current_month() # "YYYY-MM"
rows = query_summary(month) # your DB read, PII-free columns
rendered = format_summary_table(rows) # a markdown string
return {"rendered": rendered, "data": {"month": month, "categories": rows}}
TOOL_SPECS = [
ToolSpec(
"get_month_summary",
"Spend summary for a month (YYYY-MM, default current). Call this first for "
"'how am I doing this month'.",
obj({"month": {"type": "string"}}),
get_month_summary,
),
]
SPEC_BY_NAME = {s.name: s for s in TOOL_SPECS}
A few helpers are yours to supply: current_month() returns "YYYY-MM",
query_summary(month) runs a read over PII-free columns, and format_summary_table(rows)
returns markdown. Step 2 adds two more, redact_digits(s) (mask digit runs in a string) and
format_rows(rows) (a markdown table), plus a DB_PATH constant.
Step 2: Read private data behind a column-level authorizer
The moment you offer a general run_sql tool, you have handed the model a way to SELECT payee, memo FROM transactions. A system prompt saying “do not read PII” is not a control.
SQLite gives you a real one: a connection-scoped authorizer callback that fires for every
column access and can veto it.
Python’s set_authorizer calls your callback for each attempted access; return SQLITE_OK
to allow, SQLITE_DENY to abort the whole statement with an error, or SQLITE_IGNORE to
substitute NULL. For an SQLITE_READ action the callback receives the table and column
names as its second and third arguments, which is exactly the hook we need.
import sqlite3
from contextlib import contextmanager
READ_DENY = {("transactions", "payee"), ("transactions", "memo"),
("transactions", "raw_ofx"), ("accounts", "acct_hash")}
def _authorizer(action, arg1, arg2, dbname, trigger):
if action == sqlite3.SQLITE_READ:
return sqlite3.SQLITE_DENY if (arg1, arg2) in READ_DENY else sqlite3.SQLITE_OK
if action in (sqlite3.SQLITE_SELECT, sqlite3.SQLITE_FUNCTION):
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY # deny writes, ATTACH, PRAGMA, everything else
@contextmanager
def agent_connect(db_path):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA query_only = ON") # set BEFORE the authorizer denies PRAGMA
conn.set_authorizer(_authorizer)
try:
yield conn
finally:
conn.close()
Now a run_sql tool can be genuinely useful and still safe. Allow only SELECT/WITH,
reject a small denylist of keywords, run through agent_connect, and as defense in depth
scrub digit-runs from every string cell in case a future column carries an embedded number.
import re
FORBIDDEN = {"attach", "pragma", "insert", "update", "delete", "drop"}
async def run_sql(args: dict) -> dict:
q = (args.get("query") or "").strip().rstrip(";")
low = q.lower()
if not (low.startswith("select") or low.startswith("with")):
return {"error": "read-only: only SELECT/WITH permitted"}
if set(re.split(r"[^a-z]+", low)) & FORBIDDEN:
return {"error": "forbidden keyword"}
try:
with agent_connect(DB_PATH) as conn:
rows = [dict(r) for r in conn.execute(q).fetchmany(200)]
except sqlite3.DatabaseError:
return {"error": "denied: query touched a blocked column (payee/memo/raw_ofx)"}
rows = [{k: redact_digits(v) if isinstance(v, str) else v for k, v in r.items()}
for r in rows]
return {"rendered": format_rows(rows), "data": {"rows": rows}}
If the model asks for payee, the statement aborts and it gets a plain “denied” string it
can recover from. It never sees the value. The guarantee lives in the database engine, so a
curious model cannot talk its way past it the way it could slip past a system-prompt rule.
Step 3: The server, and the two-channel result
Build the low-level server straight off the registry, so listing and calling both read the
same TOOL_SPECS.
import json
from mcp import types
from mcp.server.lowlevel import Server
def jsonable(obj: dict) -> dict:
"""Coerce dates/Decimals so structuredContent can serialize."""
return json.loads(json.dumps(obj, default=str))
def build_server() -> Server:
server = Server("budget")
@server.list_tools()
async def _list() -> list[types.Tool]:
return [types.Tool(name=s.name, description=s.description, inputSchema=s.input_schema)
for s in TOOL_SPECS]
@server.call_tool()
async def _call(name: str, arguments: dict):
spec = SPEC_BY_NAME.get(name)
if spec is None:
return {"error": f"unknown tool: {name}"}
result = await spec.handler(arguments or {})
rendered = result.get("rendered")
if rendered:
structured = {"data": result["data"]} if "data" in result else result
return [types.TextContent(type="text", text=rendered)], jsonable(structured)
return jsonable(result)
return server
Returning a tuple of (content, structuredContent) is deliberate. The MCP spec says
structured content is returned as a JSON object in the structuredContent field, and that
for backwards compatibility a tool returning structured content should also return the
serialized JSON in a text block. So the human-readable table goes in the text block, and the
same data goes in structuredContent where the model can resolve a row by id, or a merchant
by exact string, without re-parsing your markdown. Run it over stdio:
import asyncio
from mcp.server.stdio import stdio_server
async def run_stdio():
server = build_server()
async with stdio_server() as (read, write):
await server.run(read, write, server.create_initialization_options())
if __name__ == "__main__":
asyncio.run(run_stdio())
Use it, then verify it
Register the server with your client. For Claude Code:
claude mcp add budget -- python -m budget.mcp_server
Ask a question in the client (“what did I spend on groceries last month?”) and it will call
get_month_summary or run_sql and answer from the returned data. Then verify the guarantee
yourself, from the client, with the one query that must fail:
# In the MCP client, call run_sql with:
# SELECT payee FROM transactions LIMIT 1
# Expected: {"error": "denied: query touched a blocked column ..."}
If that returns rows instead of a denial, your authorizer is not attached to the connection the tool actually uses. A green run is a real signal: the sensitive column is unreadable even to a direct SQL request.
Gotchas
- The JSON-Schema shorthand does not survive the wire. Trap: writing an input schema
as
{"month": str}, the way some higher-level SDK helpers accept. Symptom:tools/listfails to serialize and the client shows your server with no tools. Escape: emit a real JSON Schema object,{"type": "object", "properties": {...}, "required": [...]}. The raw protocol serializes exactly what you hand it. - A “do not read PII” prompt is not access control. Trap: shipping a
run_sqltool and trusting the description to keep the model out ofpayee/memo. Symptom: account numbers and raw statement text land in the model’s context the first time it gets curious. Escape: the column-level authorizer above, plusPRAGMA query_only, plus a digit-run redactor on returned strings. Set the pragma before the authorizer, since the authorizer then denies further pragmas. - structuredContent chokes on dates and Decimals. Trap: putting a
datetimeor aDecimalstraight into the data payload. Symptom: the SDK raises at call time trying to serialize the result. Escape: round-trip throughjson.dumps(obj, default=str)before returning, asjsonabledoes. - An unscoped tool floods the context window. Trap: an anomaly or history tool that
returns everything by default; over two years of statements that is a wall of rows.
Symptom: one call blows the model’s context and the answer degrades. Escape: scope the
output with
month/limitarguments while keeping the detection baseline on full history, so the statistics stay honest but the payload stays small. - Bank exports are hostile XML. Trap: parsing an OFX/QFX file with the standard library
xml.etree. Symptom: a crafted file triggers XXE or a billion-laughs entity expansion at import. Escape: parse untrusted XML withdefusedxml, which disables entity expansion and external entities by default.
Sources
- MCP: Tools specification — structuredContent, the “also return serialized JSON in a text block” rule, output schema, and tool-execution errors as self-correcting feedback.
- Python
sqlite3: set_authorizer — the per-access callback, SQLITE_OK/DENY/IGNORE semantics, and the table/column arguments for SQLITE_READ. - SQLite: Compile-Time Authorization Callbacks — the authoritative behavior of the authorizer and DENY vs IGNORE.
- defusedxml — secure substitutes for Python’s XML parsers against XXE, billion laughs, and quadratic blowup.
Changelog
- local-budget: local-first, agent-first spending agent (703a9ae)
- feat(mcp): transmit the data payload via structuredContent (6eb2602)
- feat(tools): month/limit scoping for find_anomalies (2ecb55c)
- feat(tools): list_categories vocabulary tool + recoverable category errors (0ce5fb4)
- fix(security): read-deny raw payee/memo to the agent authorizer (578c608)
- fix(security): redact account-number runs in stored payee/memo at import (c4afbc5)
- fix(security): reject cross-origin requests on mutating dashboard routes (0fb8e02)
- test(security): pin hostile-OFX XML safety (XXE, entity bomb, DOCTYPE) (0c9e8bf)