Build Your Own HEARTH
A local MCP gateway for Claude Code on a Windows machine with an NVIDIA GPU — every tool call logged, a local model on tap, and proof it's all working. ~90 minutes, no prior MCP experience required. A hearth is the small fire that never goes out at the center of the camp: always warm, always watched, everything cooked on it. That's what you're building — a small, always-on fire your AI agents gather around.
What you're building, and why
When Claude Code (or any frontier agent) works on your machine today, it reaches for tools ad hoc — a shell command here, a file edit there — and none of it is captured anywhere you control. Two things are wrong with that picture:
- You're paying frontier prices for grunt work. Summarizing a log, drafting boilerplate, extracting fields from text — a local model on your own GPU does these at the cost of electricity, and your GPU is sitting right there.
- You have no record. When something goes sideways, or when you want to know what your agent actually did, there's nothing to query.
HEARTH fixes both with one small daemon: a local MCP gateway that exposes tools (file ops, a local-model generate, whatever you add) to Claude Code over HTTP, and writes every single call to a ledger — a JSONL file plus a SQLite index you can query. Claude does the thinking; your machine does the hands; the ledger is the receipts.
MCP (Model Context Protocol) is the open standard that makes this plug-and-play: Claude Code natively speaks it, so once your gateway is up, its tools appear in Claude like built-ins.
What you need
| Piece | Spec / version | Why |
|---|---|---|
| Windows PC | Windows 10/11 | the whole point — this guide is for the consumer-Windows crowd |
| NVIDIA GPU | ~12 GB VRAM | runs a 14B coding model fully on-GPU at pleasant speed |
| RAM / CPU | 32 GB+ / modern fast CPU | lets you stretch to bigger models with partial CPU offload later |
| Python | 3.11+ (3.12 recommended) | the gateway is ~150 lines of Python |
| Ollama | latest Windows build | the easiest local-model server; one installer, one command |
| Claude Code | latest | your frontier agent and MCP client |
| A local model | qwen2.5-coder:14b (~9 GB) | fits comfortably in 12 GB VRAM; strong at code/text chores. (Stretch: qwen3-coder:30b runs with CPU offload if you have the RAM — slower but smarter.) |
Step 1 — Install Ollama and pull a model
- Download and run the Windows installer from
https://ollama.com/download. It installs a background server on port11434that starts when you log in. - Open PowerShell and pull the model (~9 GB download):
ollama pull qwen2.5-coder:14b
ollama run qwen2.5-coder:14b "Write a haiku about a fire that never goes out."And confirm the HTTP API answers (this is what the gateway will call):
Invoke-RestMethod http://127.0.0.1:11434/api/tags | Select-Object -Expand models | Format-Table name
Step 2 — Python environment + the MCP SDK
mkdir C:\hearth cd C:\hearth python -m venv venv .\venv\Scripts\pip install "mcp[cli]" httpx
.\venv\Scripts\python -c "import mcp; print('mcp OK:', mcp.__name__)"
Step 3 — The gateway (one file, with a ledger)
Save this as C:\hearth\hearth_gateway.py. It exposes five tools over MCP and logs every call to both ledger.jsonl (append-only, human-readable) and ledger.sqlite (queryable). The logging decorator is the heart of HEARTH — everything that passes through the fire is remembered.
"""HEARTH: a minimal local MCP gateway with a tool-call ledger.
Tools appear in Claude Code automatically once wired via .mcp.json.
Every call is appended to ledger.jsonl and indexed in ledger.sqlite.
"""
import functools
import hashlib
import json
import sqlite3
import subprocess
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
import httpx
from mcp.server.fastmcp import FastMCP
HOME = Path(__file__).parent
SCOPE = Path("C:/") # tools may only touch paths under here — tighten to your projects dir!
OLLAMA = "http://127.0.0.1:11434"
MODEL = "qwen2.5-coder:14b"
mcp = FastMCP("hearth")
# ---------- the ledger ----------
_db = sqlite3.connect(HOME / "ledger.sqlite", check_same_thread=False)
_db.execute("""CREATE TABLE IF NOT EXISTS events(
id TEXT PRIMARY KEY, ts TEXT, tool TEXT, ok INTEGER,
duration_ms REAL, args_preview TEXT, error TEXT)""")
_db.commit()
def _digest(obj) -> str:
return "sha256:" + hashlib.sha256(
json.dumps(obj, default=str, sort_keys=True).encode()).hexdigest()[:16]
def ledgered(fn):
"""Every tool call becomes one ledger event. This is the whole trick."""
@functools.wraps(fn)
def wrapper(*args, **kwargs):
started = time.perf_counter()
event = {
"event_id": str(uuid.uuid4()),
"ts": datetime.now(timezone.utc).isoformat(),
"tool": fn.__name__,
"args_preview": json.dumps(kwargs, default=str)[:300],
"args_digest": _digest(kwargs),
"ok": True, "error": None,
}
try:
result = fn(*args, **kwargs)
event["result_digest"] = _digest(result)
return result
except Exception as exc:
event.update(ok=False, error=f"{type(exc).__name__}: {exc}")
raise
finally:
event["duration_ms"] = round((time.perf_counter() - started) * 1000, 2)
with open(HOME / "ledger.jsonl", "a", encoding="utf-8") as f:
f.write(json.dumps(event) + "\n")
_db.execute("INSERT INTO events VALUES(?,?,?,?,?,?,?)",
(event["event_id"], event["ts"], event["tool"],
int(event["ok"]), event["duration_ms"],
event["args_preview"], event["error"]))
_db.commit()
return wrapper
def _in_scope(path: str) -> Path:
p = (SCOPE / path).resolve() if not Path(path).is_absolute() else Path(path).resolve()
if not str(p).lower().startswith(str(SCOPE.resolve()).lower()):
raise ValueError(f"path {p} is outside HEARTH scope {SCOPE}")
return p
# ---------- the tools ----------
@mcp.tool()
@ledgered
def hearth_status() -> dict:
"""HEARTH self-test: ledger location and total event count."""
n = _db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
return {"hearth": "burning", "ledger": str(HOME / "ledger.jsonl"), "events": n}
@mcp.tool()
@ledgered
def read_file(path: str, max_bytes: int = 200_000) -> dict:
"""Read a text file (scope-checked)."""
data = _in_scope(path).read_text(encoding="utf-8", errors="replace")
return {"path": path, "truncated": len(data) > max_bytes, "content": data[:max_bytes]}
@mcp.tool()
@ledgered
def write_file(path: str, content: str) -> dict:
"""Write a text file (scope-checked); creates parent dirs."""
p = _in_scope(path)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(content, encoding="utf-8")
return {"path": str(p), "bytes": len(content)}
@mcp.tool()
@ledgered
def run_tests(directory: str, command: str = "python -m pytest -q --tb=short") -> dict:
"""Run a test command in a directory; returns a compact digest, never full output."""
p = subprocess.run(command, cwd=_in_scope(directory), shell=True,
capture_output=True, text=True, timeout=600)
tail = (p.stdout + p.stderr).strip().splitlines()[-15:]
return {"exit_code": p.returncode, "ok": p.returncode == 0, "tail": tail}
@mcp.tool()
@ledgered
def local_generate(prompt: str, system: str = "", max_tokens: int = 1024) -> dict:
"""Generate text with the LOCAL model (free, runs on your GPU).
Use for: summarizing, extracting, boilerplate, drafts, classification."""
r = httpx.post(f"{OLLAMA}/api/generate", json={
"model": MODEL, "prompt": prompt, "system": system or None,
"stream": False, "options": {"num_predict": max_tokens}}, timeout=300)
r.raise_for_status()
body = r.json()
return {"text": body.get("response", ""),
"model": MODEL,
"tokens_in": body.get("prompt_eval_count"),
"tokens_out": body.get("eval_count"),
"duration_ms": round(body.get("total_duration", 0) / 1e6)}
if __name__ == "__main__":
mcp.settings.host = "127.0.0.1"
mcp.settings.port = 8710
mcp.run(transport="streamable-http")
Before running: change SCOPE from C:/ to the folder your projects live in (e.g. C:/work). The scope check is your seatbelt — tools refuse to touch anything outside it.
cd C:\hearth .\venv\Scripts\python hearth_gateway.pyYou should see
Uvicorn running on http://127.0.0.1:8710. Leave it running. In a second window, prove a real MCP client can call a tool end to end:
@'
import asyncio
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def main():
async with streamablehttp_client("http://127.0.0.1:8710/mcp") as (r, w, _):
async with ClientSession(r, w) as s:
await s.initialize()
tools = await s.list_tools()
print("tools:", [t.name for t in tools.tools])
result = await s.call_tool("hearth_status", {})
print("status:", result.content[0].text)
asyncio.run(main())
'@ | Out-File -Encoding ascii C:\hearth\test_client.py
C:\hearth\venv\Scripts\python C:\hearth\test_client.py
Expected: a list of five tools, then "hearth": "burning". Now check your receipts — the call you just made is already in the ledger:
Get-Content C:\hearth\ledger.jsonl -Tail 2
Step 4 — Wire it into Claude Code
Two small files in the project folder where you use Claude Code (e.g. C:\work\myproject):
1 · .mcp.json — tells Claude Code the gateway exists:
{
"mcpServers": {
"hearth": {
"type": "http",
"url": "http://127.0.0.1:8710/mcp"
}
}
}
2 · CLAUDE.md — add a section telling Claude when to use it. Without this, Claude has the tools but no habit:
## Local-first offload (HEARTH) A local model is available via the `hearth` MCP server's `local_generate` tool, plus ledgered file/test tools. Before spending frontier tokens on a self-contained sub-task, delegate it to the local model: - summarizing / condensing a file, log, or diff you have already read - extracting structured data (fields, lists, JSON) from unstructured text - generating boilerplate (config, test scaffold, docstring, commit draft) - classifying / labeling / yes-no triage over a chunk of text Rules: the local model has NO repo access — pass all needed context in the prompt. If the output is unusable, do the task yourself; one retry max. First call after boot pays a model-load tax (~10s) — that is not a failure.
.mcp.json you must fully restart the Claude Code application AND start a new session in that project. An existing session — even one that helped you write the config — will never see the server. On the first new session, Claude Code will ask you to approve the project's hearth server: say yes.Use the hearth_status tool, then use local_generate to write a two-line poem about a hearth.You should see Claude call both tools (they show as
mcp__hearth__...) and the poem should come back from your GPU, not the cloud. Then the money shot — prove the ledger caught it all:
C:\hearth\venv\Scripts\python -c "import sqlite3; [print(r) for r in sqlite3.connect(r'C:\hearth\ledger.sqlite').execute('SELECT ts, tool, ok, duration_ms FROM events ORDER BY ts DESC LIMIT 5')]"
Every tool call your agent has ever made, timestamped, with durations. That's HEARTH working.Step 5 — Keep the fire lit (auto-start)
Save as C:\hearth\start-hearth.cmd:
@echo off cd /d C:\hearth echo [%date% %time%] HEARTH starting >> hearth.log venv\Scripts\python.exe hearth_gateway.py >> hearth.log 2>&1
Register it to run at logon (plain PowerShell, no admin needed):
$action = New-ScheduledTaskAction -Execute 'C:\hearth\start-hearth.cmd' $trigger = New-ScheduledTaskTrigger -AtLogOn -User "$env:USERDOMAIN\$env:USERNAME" Register-ScheduledTask -TaskName 'Hearth' -Action $action -Trigger $trigger
python.exe, not pythonw.exe, in the wrapper. pythonw under Task Scheduler dies silently with no log and no error — we learned this the hard way. With python.exe + the log redirect above, any failure leaves evidence in hearth.log.Start-ScheduledTask -TaskName Hearth Start-Sleep 5 Test-NetConnection 127.0.0.1 -Port 8710 | Select-Object TcpTestSucceeded
TcpTestSucceeded : True means the fire relights itself every time you log in. Reboot when convenient and check once more.Troubleshooting — the traps we actually hit
| Symptom | Cause | Fix |
|---|---|---|
Claude Code doesn't show any hearth tools | MCP config loads at app startup only | fully restart the app AND open a new session; approve the server when prompted |
| Gateway fails to parse a JSON config you wrote from PowerShell | PowerShell 5.1's Out-File -Encoding utf8 writes a UTF-8 BOM | write JSON from Python, an editor, or use -Encoding ascii |
| Gateway "runs" from Task Scheduler but nothing listens | pythonw.exe dying silently | use python.exe with output redirected to a log |
| Port 8710 behaves strangely / calls hit a stale server | an old gateway process still holds the port | Get-NetTCPConnection -LocalPort 8710 -State Listen → stop that PID, restart the task |
First local_generate after boot is slow or times out | model loading into VRAM (~10s, once) | not a failure; retry once. Optionally warm it at logon with a tiny generate call |
local_generate returns nonsense / ignores your text | local 14B models are honest workers, not geniuses | give them ALL the context in the prompt, ask for one narrow thing, and verify — the ledger makes their error rate measurable, which is the point |
Where this goes next
You now have the kernel of something bigger. In rough order of payoff:
- Add per-caller keys — an
X-Hearth-Keyheader and acallers.jsonturn "who did what" from a guess into a ledger field. - Add coarser tools — a
git_commit_pushthat stages/commits/pushes in one audited call; a lint digest; whatever your inner loop repeats. - Query your economics — the SQLite ledger already knows how many calls went to the local model vs. how long they took. One evening of SQL gives you a dashboard proving what your GPU is saving you.
- The learning loop — in the full HEARTH design, the ledger feeds a projection layer that derives what your machines are actually capable of from evidence, and routes each task to the cheapest runner that can do it. That's a bigger story — the ledger you built today is its foundation.
Light it, keep it lit, and let everything cook on the same fire.