← Steppe Integrations / Articles

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:

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

PieceSpec / versionWhy
Windows PCWindows 10/11the whole point — this guide is for the consumer-Windows crowd
NVIDIA GPU~12 GB VRAMruns a 14B coding model fully on-GPU at pleasant speed
RAM / CPU32 GB+ / modern fast CPUlets you stretch to bigger models with partial CPU offload later
Python3.11+ (3.12 recommended)the gateway is ~150 lines of Python
Ollamalatest Windows buildthe easiest local-model server; one installer, one command
Claude Codelatestyour frontier agent and MCP client
A local modelqwen2.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.)
You have a copilot for this tutorial. Every step below is something Claude Code (or Claude in your browser) can help with. Stuck on an error? Paste it into Claude and ask. You're not just building an agent workbench — you're allowed to use the agent to build it.

Step 1 — Install Ollama and pull a model

  1. Download and run the Windows installer from https://ollama.com/download. It installs a background server on port 11434 that starts when you log in.
  2. Open PowerShell and pull the model (~9 GB download):
ollama pull qwen2.5-coder:14b
✔ Test it — ask the model something and watch your GPU do the work (first call loads the model into VRAM, ~10s; after that it's fast):
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
✔ Test it
.\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.

✔ Test it — start the gateway in one PowerShell window:
cd C:\hearth
.\venv\Scripts\python hearth_gateway.py
You 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.
⚠ The restart trap (this one costs everyone an hour). Claude Code loads MCP config at startup. After creating .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.
✔ Test it — in the fresh session, type:
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
⚠ Use 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.
✔ Test it — stop your manual gateway window (Ctrl+C), then:
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

SymptomCauseFix
Claude Code doesn't show any hearth toolsMCP config loads at app startup onlyfully restart the app AND open a new session; approve the server when prompted
Gateway fails to parse a JSON config you wrote from PowerShellPowerShell 5.1's Out-File -Encoding utf8 writes a UTF-8 BOMwrite JSON from Python, an editor, or use -Encoding ascii
Gateway "runs" from Task Scheduler but nothing listenspythonw.exe dying silentlyuse python.exe with output redirected to a log
Port 8710 behaves strangely / calls hit a stale serveran old gateway process still holds the portGet-NetTCPConnection -LocalPort 8710 -State Listen → stop that PID, restart the task
First local_generate after boot is slow or times outmodel 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 textlocal 14B models are honest workers, not geniusesgive 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:

Light it, keep it lit, and let everything cook on the same fire.

Derek Ciula · Steppe Integrations · July 2026 · distilled from a live build, written with Claude · more writing →