I spent most of last quarter bouncing between Puerto Ayora, Puerto Baquerizo Moreno, and a research boat that anchored off Isabela. My job was debugging a multi-agent refactor pipeline I had no intention of pausing just because I was looking at frigatebirds instead of pull requests. The Charles Darwin Research Station hands out a flaky captive-portal Wi-Fi that drops every 12 minutes and a satellite uplink that goes from "usable" to "dead" the moment the salt spray hits the antenna. So I built an offline-first MCP server with a persistent bridge queue and a HolySheep-backed cloud burst layer for the moments I do get online. This article is the field-tested version of that setup, with timings, prices, and the bugs I actually hit.
Why Offline-First MCP Matters for Agentic Coding
Model Context Protocol (MCP) was designed for always-connected hosts with a fat JSON-RPC server in the cloud. The moment you drop to 0.3 Mbps uplink, three things break in sequence: tool discovery (slow), tool call round-trips (multi-second), and finally the agent itself (unable to decide without recent data). The fix is not to wait for connectivity — it's to flip the architecture so the state lives on the device and the model is a stateless planner that you call when bandwidth is plentiful. Concretely:
- A local stdio MCP server owns ground-truth tools (git, pytest, sqlite, file system). It is the only thing the agent talks to first.
- A bridge daemon on the same machine holds a durable SQLite queue. While offline, it accepts jobs and waits; while online, it drains.
- A HolySheep-backed LLM endpoint receives compacted summaries from the local model and returns plans, code edits, or test specs. Latency from South America back to
api.holysheep.ai/v1stayed under 180ms p95 in my measurement window, which beats every US-gateway provider I tried.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Editor / Agent Loop (your laptop on the boat) │
│ ┌──────────────┐ JSON-RPC ┌────────────────────────────┐ │
│ │ agent_loop │──────────────▶│ mcp_local_server.py (stdio)│ │
│ └──────┬───────┘ │ ├─ tools/list │ │
│ │ │ ├─ tools/call │ │
│ │ HTTPS (when online) │ └─ cache_get/_set │ │
│ ▼ └────────┬───────────────────┘ │
│ ┌──────────────┐ sqlite kv│ ▲ │
│ │ HolySheep │◀───────────────bridge daemon ──┐ │
│ │ DeepSeek V3.2│ p50 ~41ms │ watch + flush │ │
│ └──────────────┘ ▼ │ │
│ bridge.sqlite (WAL) │ │
└─────────────────────────────────────────────────────────────────┘
The single most important property is that the local MCP server is the source of truth. The cloud model is replaceable and, in fact, gets swapped between deepseek-v3.2 ($0.42/MTok) for routine work and claude-sonnet-4.5 ($15/MTok) for the hard refactors. The local server never changes.
Implementation 1 — Local stdio MCP Server (zero external deps)
No pip install mcp on a satellite-connected boat. Everything below is stdlib so it runs in a freshly minted venv:
#!/usr/bin/env python3
"""
mcp_local_server.py — stdio JSON-RPC MCP server for Galapagos offline work.
Implements tools/list, tools/call, resources/list. No third-party deps.
Usage (from an agent): pipe a JSON-RPC line, read the last JSON line back.
"""
import json, sys, sqlite3, subprocess
from pathlib import Path
DB_PATH = Path.home() / ".holysheep" / "offline_cache.sqlite"
TOOLS = [
{"name": "run_tests",
"description": "Run pytest on the current project, return tail of output.",
"inputSchema": {"type": "object",
"properties": {"path": {"type": "string", "default": "."},
"timeout": {"type": "integer", "default": 60}}}},
{"name": "git_commit",
"description": "Make a local git commit with the given message.",
"inputSchema": {"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"]}},
{"name": "cache_get",
"description": "Read a cached value previously set by the bridge daemon.",
"inputSchema": {"type": "object",
"properties": {"key": {"type": "string"}}, "required": ["key"]}},
]
def init_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB_PATH)
con.execute("CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT, ts INTEGER)")
con.execute("PRAGMA journal_mode=WAL") # <-- critical for concurrent writes
con.commit()
return con
CON = init_db()
def handle(req):
method, rid = req.get("method"), req.get("id")
if method == "initialize":
return {"jsonrpc": "2.0", "id": rid, "result": {
"protocolVersion": "2024-11-05",
"serverInfo": {"name": "galapagos-local-mcp", "version": "1.2.0"},
"capabilities": {"tools": {}, "resources": {}}}}
if method == "tools/list":
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}}
if method == "tools/call":
p = req["params"]; name, args = p["name"], p.get("arguments", {})
if name == "run_tests":
try:
out = subprocess.run(["pytest", "-q", args.get("path", ".")],
capture_output=True, text=True,
timeout=args.get("timeout", 60)).stdout[-2000:]
return {"jsonrpc": "2.0", "id": rid, "result":
{"content": [{"type": "text", "text": out or "(no output)"}]}}
except subprocess.TimeoutExpired:
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32001, "message": "pytest timeout"}}
if name == "git_commit":
subprocess.run(["git", "-C", args.get("path", "."), "add", "-A"], check=True)
subprocess.run(["git", "-C", args.get("path", "."), "commit",
"-m", args["message"]], check=True)
return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text", "text": "committed"}]}}
if name == "cache_get":
row = CON.execute("SELECT v FROM kv WHERE k=?", (args["key"],)).fetchone()
return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text", "text": row[0] if row else ""}]}}
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "Method not found"}}
for line in sys.stdin:
line = line.strip()
if not line: continue
try:
resp = handle(json.loads(line))
except Exception as e:
resp = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": str(e)}}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
Two things worth flagging. First, PRAGMA journal_mode=WAL is non-negotiable — without it the bridge daemon's worker threads will intermittently wedge on database is locked. Second, every JSON-RPC response goes on its own line, which is what makes the client-side parsing in step 3 work cleanly.
Implementation 2 — Bridge Daemon with Durable Queue and Backpressure
#!/usr/bin/env python3
"""
bridge_daemon.py — drains a local request queue once satellite uplink returns.
Watches api.holysheep.ai/v1/models every 30s. While offline, accumulates jobs;
while online, flushes them concurrently (max 4) with exponential backoff.
Exponential backoff is per-job-id so failures don't pile into a thundering herd.
"""
import json, os, time, sqlite3, threading, urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
BASE = "https://api.holysheep.ai/v1"
API_KEY = os.environ["HOLYSHEEP_API_KEY"]
DB = Path.home() / ".holysheep" / "bridge.sqlite"
def init():
DB.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(DB, check_same_thread=False)
con.execute("PRAGMA journal_mode=WAL")
con.execute("""CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
payload TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
finished_at INTEGER)""")
con.commit()
return con
CON, LOCK = init(), threading.Lock()
def online():
try:
req = urllib.request.Request(f"{BASE}/models",
headers={"Authorization": f"Bearer {API_KEY}"})
with urllib.request.urlopen(req, timeout=4) as r:
return r.status == 200
except Exception:
return False
def enqueue(payload):
with LOCK:
CON.execute("INSERT INTO jobs(payload, created_at) VALUES (?, ?)",
(json.dumps(payload), int(time.time())))
CON.commit()
return CON.total_changes
def send(payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(f"{BASE}/chat/completions", data=data, method="POST",
headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=45) as r: # bumped from 30s for satellite jitter
return json.loads(r.read())
def worker(job_id, payload):
try:
send(payload)
with LOCK:
CON.execute("UPDATE jobs SET status='done', finished_at=? WHERE id=?",
(int(time.time()), job_id))
return True
except urllib.error.HTTPError as e:
if e.code == 429: # rate limited → come back later
with LOCK:
CON.execute("UPDATE jobs SET status='pending' WHERE id=?", (job_id,))
time.sleep(2 + (job_id % 4)) # 2–6s jitter
elif e.code in (401, 403): # auth → drop the job, alert humans
with LOCK:
CON.execute("DELETE FROM jobs WHERE id=?", (job_id,))
raise SystemExit("HOLYSHEEP_API_KEY rejected — aborting daemon")
else:
with LOCK:
CON.execute("UPDATE jobs SET attempts=attempts+1 WHERE id=?", (job_id,))
raise
def flush():
with LOCK:
rows = CON.execute("SELECT id, payload FROM jobs WHERE status='pending' "
"ORDER BY id LIMIT 32").fetchall()
if not rows: return 0
done = 0
with ThreadPoolExecutor(max_workers=4) as ex:
futs = {ex.submit(worker, jid, json.loads(p)): jid for jid, p in rows}
for f in as_completed(futs):
try: f.result(); done += 1
except Exception: pass
return done
if __name__ == "__main__":
while True:
if online():
n = flush()
print(f"online flushed={n}", flush=True)
else:
print("offline", flush=True)
time.sleep(30)
The 401/403 hard-exit is intentional: there is no point hammering the API with a revoked key from a dinghy at four in the morning. The daemon should fail loud and let a human reload credentials.
Implementation 3 — Agent Loop That Calls Both
#!/usr/bin