DEV Community

Cover image for One MCP Server, Two Models: An Always-On Ops Agent That Costs $0
Giulio D'Erme
Giulio D'Erme

Posted on

One MCP Server, Two Models: An Always-On Ops Agent That Costs $0

A companion to Part 4: your first MCP server and the hardening deep dive. Part of Claude Code, Beyond the Prompt.


Part 4 gave Claude hands: an MCP server exposing narrow, audited tools onto my systems. It works. But it has a shape problem that took me a while to name.

Claude is interactive and metered. It acts when I'm at the keyboard, and every action costs tokens. My systems, meanwhile, run twenty-four hours a day. Something fails at 3 a.m. and there is nobody home.

What I wanted was an always-on agent: watching the journal, triaging recurring errors, and able to answer "why is that service failing?" from my phone, for free.

The answer turned out not to be a second Claude. It was a second client on the same tool server.

And that's the idea worth stealing, so let me put it up front:

Once the enforcement lives in the tools, the model becomes a swappable client. The fence isn't in the model, it's in the server — so you can plug a cheap, dumb local model into the same fence and let it be wrong, safely.

That is true for safety. It turns out not to be true for cost, and I only found that out by measuring — after I'd already built the thing. There's a section on that below, with the numbers that killed a feature I was rather proud of.

The architecture: one server, many clients

Four pieces run on the box:

  1. The MCP tool server. The hardened boundary from Part 4: around thirty narrow tools, sandboxed, audit-logged, bearer-auth, bound to a private network only. Never exposed to the internet.
  2. Claude, connected as an MCP client, for interactive work with me in the loop.
  3. A local model, served by Ollama on the same machine, connected as another client. It never talks to the internet.
  4. Three autonomous entry points that drive the local model: a Telegram bot, a watchdog, and a nightly audit.

The important part is what it is not: the local model is not Claude's assistant, and Claude is not its supervisor. They're peers. Two clients, one fence.

The tool catalogue

Here's the actual surface, grouped by what it does. Note that every tool carries its guardrail in the tool, not in a prompt.

Observation (read-only)

Tool What it does The guardrail
db_query_ro Run a SQL query SELECT only via a read-only DB role, 500-row cap, 5s statement timeout
read_file Read a repo file Path traversal blocked; secrets (.env, *.key, .ssh/) refused server-side
list_dir_live / rg_search_live List / grep live files Confined to allowlisted roots
journalctl_tail / journalctl_grep Read a unit's journal Read-only, line-capped, output truncated
systemctl_is_active / systemctl_status / systemctl_cat Unit liveness, status, unit file Read-only, no restart capability
port_check / health_probe_http / env_presence_check Is it listening, is it healthy, is the env var set env_presence_check returns presence only, never values
sha256_file Checksum a deployed file Read-only; used to verify a deploy matches git

Retrieval (this is the one people skip)

Tool What it does
code_search Hybrid semantic + BM25 search over the indexed repo. Find code by intent ("where do we filter signals"), not by guessing a grep string.
docs_search Same hybrid retrieval over the knowledge base (memory / research / plans). Returns a gap_warning when the top-3 cosine similarity is below threshold, i.e. "I probably have nothing on this, don't trust these hits."

That gap_warning is the single most valuable field in the whole server. It's the difference between an agent that says "we looked at this before" and one that says "I have nothing relevant, treat it as new."

Git and GitHub (read)

git_log · gh_issue_list · gh_issue_view · gh_pr_list · gh_pr_view

Gated write

Tool What it does The gate
create_branch New branch Name must match auto/<agent>-fix-*. Nothing else is accepted.
apply_patch Apply a unified diff and commit The gauntlet below.
open_pr Push the branch, open a PR Never auto-merges. Dedups against open PRs with the same error signature. 24h rate cap.
gh_issue_create File an issue for a human Title prefix enforced; body must contain ## Evidence and ## Source sections; 24h cap.
systemctl_restart Restart a unit Allowlist only.
deploy_files Ship a file The tool itself enforces backup → checksum-verify against git → restart → health-check.
telegram_alert Page the operator info / warn / critical levels only.

The design that makes it safe: different callers, different tool surfaces

This is the part I'd most like you to take away, because it's cheap and almost nobody does it.

The server exposes ~30 tools. No caller sees all of them.

  • The Telegram bot → read-only tools. When I'm asking questions from my phone, there is no path to a write. Not "the model has been told not to." There is no write tool in its list.
  • The watchdog → read + gated write. It's the only caller that can propose a patch.
  • The nightly audit → read + gh_issue_create. It's the only caller that can open an issue. (A watchdog running every fifteen minutes with issue-creation would be a spam machine.)
  • Claude → the full set, because a human is in the loop for every call.

Same server, four different surfaces. It's per-caller least privilege, and it's the "scope the reads, per tool" lesson from the hardening piece, applied one level up.

The auto-fix gauntlet

The watchdog can open a pull request. Here's every condition it must satisfy — all of them, or the server rejects the call:

  1. ≤ 20 changed lines, ≤ 1 file. Anything bigger is not an auto-fix, it's a change.
  2. Category must be whitelisted: JSONB serialization, timezone-naive datetime, missing None check, import error, connection retry/backoff. That's it. Five known, boring, mechanical bug shapes.
  3. The error signature must have occurred at least twice in the last 30 days. This is my favourite rule: the agent may only fix bugs it has already seen recur. No novel fixes. Novelty goes to a human.
  4. The target file must not be frozen. The frozen list is everything that could actually hurt: the live money-path modules, central config, CI, systemd units, deploy scripts — and the agent's own source code, so it can't rewrite its own guardrails.
  5. The branch must match auto/*-fix-*.
  6. No renames, binaries, new files, or deletions. Modifications to one existing file, nothing else.
  7. It never pushes to the main branch. Branch, commit, open a PR, alert me. A human merges. Always.

Miss any one and the call comes back blocked, with an instruction not to retry but to escalate to a human via telegram_alert.

The philosophy underneath: the local model is allowed to be wrong. It can't do damage, so it doesn't need to be smart. Every proposal lands as a PR I read on my phone.

Why a dumber model is safe here: defense in depth

A local model is worse at tool-calling than a frontier model. It will hallucinate a tool name. So the enforcement is layered:

  • Server-side guardrails are the real wall. Every gate above is enforced in the server, not in the prompt.
  • A client-side tool whitelist sits in front of it. The tool name from the model is Unicode-normalized (NFKC, so a lookalike character can't sneak through), any namespace separator (/, :, .) is rejected, and anything not in that caller's advertised list is blocked before it ever reaches the server. This exists precisely because a weaker model hallucinating apply_patch from the read-only Telegram bot is not hypothetical.
  • The database role is the wall for data. A dedicated SELECT-only role with a connection limit, not a string check on the SQL. (The hardening deep dive is entirely about why.)
  • Secrets are blocked server-side, so no tool can read them even if asked.
  • Every call is audit-logged with a hash of its arguments, latency, and result kind. Two models, one ledger.

Notice: none of this depends on the model being good. That's the whole point.

What it actually does, day to day

3 a.m., from bed. Something looks off. I open Telegram and type "why is the X collector failing?" The local model calls journalctl_tail, then systemctl_is_active, then code_search to find the relevant function, loops through up to six tool iterations, and comes back with a three-sentence diagnosis and the log excerpt it based it on. Cost: zero. Laptop: closed.

The watchdog. A periodic vigilance pass. It finds an exception that has now fired three times in a month, recognizes the shape (JSONB serialization), confirms the file isn't frozen, builds an eight-line diff, opens auto/…-fix-jsonb-…, files a PR labelled for autofix, and sends me a warn. I read the diff on my phone and merge it, or close it. It has never once been able to merge itself.

The nightly audit. Anything novel and recurring worth a human's attention becomes a GitHub issue with a mandatory ## Evidence section (log excerpt, unit, occurrence count) and ## Source section (file path, unit, lineage). The strict format is what makes them triage-able instead of noise.

Claude, during the day. Same tools, different mode: interactive, with me in the loop. Semantic search before grepping, DB queries instead of pasted psql output, deploys through the deploy tool that enforces its own checklist.

The token dividend: the server reads, the model doesn't

Here's the thing I underestimated when I built this. I reach for these tools constantly, and safety is not the reason. Economy is.

Every tool is a compression function. It does the expensive reading on the box and hands back only the answer. Compare what a model would otherwise have to do:

Without the server With the tool
SSH in, cat a file, paste 800 lines into the chat read_file returns it, path-checked and byte-capped
Grep the repo, then read ten files to find one function code_search returns the handful of relevant chunks: roughly 300 tokens instead of 6,000
Paste a 5,000-line journal dump journalctl_tail returns a capped, filtered tail
Run psql, paste the whole result table db_query_ro returns at most 500 structured rows
Re-read the knowledge base to check a past decision docs_search returns the matching memo, plus a gap_warning if there is no matching memo
cat the env file to check a variable is set env_presence_check returns present: true. Not the value. Not the file.
Read a deployed file to check it matches git sha256_file returns a hash

Look hard at those last two. The question was "is it set?" and "does it match?", so the tool returns a boolean and a hash. Zero tokens spent on content the model never needed. The answer, not the material.

This is the same lever as "use a subagent for heavy reading" from the token piece: spend the 50,000 tokens of scanning somewhere that isn't your main context, and bring back the 500-token conclusion. An MCP tool is exactly that, made permanent — the fan-out happens server-side, every single call, without you having to think about it.

And here's the part I find genuinely satisfying: the guardrail and the token budget turn out to be the same line of code. The row cap that stops a runaway query is the row cap that stops a 40,000-token result. The path confinement that blocks .ssh/ is what stops the model wandering into directories it never needed. The truncation on a log tail is both a safety valve and a cost control.

That isn't a coincidence. "Return only what was asked for" is simultaneously the security principle and the efficiency principle. Harden the tool properly and you get the cheaper bill for free — or, put the other way round, if your tool returns a wall of text, it's both expensive and insecure, and you should fix it once.

Then I tried to go one step further, and measured why it failed

If every tool is a compression function, the obvious next move is to compress the whole investigation. Give the frontier model one more tool:

ask_local(question) -> { answer, evidence, grounded, ... }
Enter fullscreen mode Exit fullscreen mode

Claude hands over a bulk, mechanical question — "scan six hours of journal for unit X, give me the distinct error signatures and their counts" — the local model does the five tool calls and the reading in its own free context, and Claude gets back three sentences instead of twenty thousand tokens of logs. Model-to-model delegation. The ultimate version of the token dividend.

I built it. It works. It ships disabled. Here's why, because the why is worth more than the feature.

The design decision I'd defend anywhere

ask_local returns evidence, not just an answer.

This is non-negotiable, and it's the same lesson as the dead collector. An unverifiable lossy compressor is a hallucination-laundering machine: to the caller, a wrong summary and a right one look identical, so it acts on either — silently. That's the worst failure mode there is.

So every answer ships with the tool calls it was built from, and grounded flips to false, with a loud warning, when no tool returned usable data — i.e. the model answered from its weights rather than from my systems. Tool errors and blocked calls never count as grounding. The caller checks grounded before acting, or it has learned nothing.

The numbers that killed it

I ran it against the real local model on the real box: twelve cores, already sitting at load 12–17 because the live systems are using them. llama3.2:3b, warm, the identical call, varying only how many tools I advertised:

Tools advertised Latency
1 180.3 s
3 28.6 s
8 252.5 s

Look at that column. It isn't monotonic. One tool is slower than three. That makes no sense if latency tracks the work — and that's the finding:

Latency here is not a function of the work. It's a function of how much CPU happens to be free at that instant. The same call takes 29 seconds or 250 seconds. It isn't slow, it's unpredictable — and you cannot budget a synchronous tool against a 10× swing.

While it runs, inference also takes about seven of the twelve cores away from the live systems. Which detonates the line I'd have written without measuring: the local model is not the cheap tenant on a shared production box. It's the most expensive one.

The model trap, free of charge

Two other models, same box, same call:

Model Result
qwen3:8b (as shipped) timeout, >600 s — never returns
qwen3:8b, thinking disabled 233 s — still far too slow
llama3.2:3b 29–250 s — the only viable family, still erratic

That first row is worth your time. Qwen3 ships with thinking mode on. Combine thinking with tool-calling on a CPU and it doesn't get slow, it never comes back — I gave it ten minutes and it was still going. Turning thinking off took it from ">600 s" to 233 s, which means the thinking alone was costing more than six minutes per turn.

If you're putting a Qwen3-family model behind tools on CPU, disable thinking or you will sit there wondering why nothing ever returns.

What I actually learned

The mechanism was fine. The 3B called the right tools. The in-process dispatch worked. And the guardrails did exactly their job: every single failed run came back grounded: false, truncated: true, with a loud warning. Not once did it hand back a confident invention. It failed noisily, which is the only acceptable way to fail.

The hardware was the problem, and it produces a sharper rule than the one I started with:

A local model works beautifully as an asynchronous background worker — a watchdog that runs every fifteen minutes, a bot you're willing to wait for. Unpredictable latency simply doesn't matter when nobody is blocked.

It does not work as a synchronous delegate for your interactive model. There is no budget you can set when the same call takes 29 s or 250 s.

And notice: my Telegram bot and my watchdog are already asynchronous. I didn't design that from insight — the hardware had decided it for me long before anyone measured. The measurement just told me why I'd been right by accident, and stopped me from being wrong on purpose.

So the corrected version of the thesis at the top of this post:

The tools make the model safe to swap. The hardware decides whether it's worth swapping. Those are two different axes, and I had quietly collapsed them into one.

Setting it up

The order that worked:

  1. Build the tool server first, not the agent. FastMCP (or the SDK of your choice) over HTTP, bearer-auth, bound to localhost or a private network. Never exposed publicly. Start with read-only tools; you'll be surprised how far that gets you.
  2. Create a read-only database role and connect through it. Grants, not string checks. Add a row cap and a statement timeout.
  3. Add the audit table on day one, not later. Log the tool, a hash of the args, latency, result kind.
  4. Wire Claude to it as an MCP client. Use it for a week. The tools you actually reach for are the ones worth hardening.
  5. Then add the local model. Ollama, one pull, and a small tool-calling loop: send the tool schemas, parse tool_calls, dispatch to the MCP server, feed results back, cap the iterations (six is plenty).
  6. Give each entry point its own tool list. Bot: read-only. Watchdog: read + gated write. This costs you ten lines and buys the whole safety story.
  7. systemd units for the server, the bot, and a timer for the watchdog.

About the model

Size the model to your hardware — and then measure it. Do not trust the model card, and do not trust me.

Two things I had wrong until I ran the numbers:

  • Bigger isn't just slower; it can be infinite. A thinking-enabled qwen3:8b never returned a single tool-calling turn on CPU. Not "took a while" — never came back.
  • A weaker model is safe, but that doesn't make it usable. The guardrails don't live in the model, so a dumber one costs you nothing in risk. It can still cost you everything in latency. On CPU, tool-calling speed is the binding constraint, not intelligence.

Practically: a 3B-class model is the only thing I'd put behind a synchronous tool on CPU, and even that is erratic under contention. A 7B–8B is fine for a background worker where nobody is waiting. A 30B-class model wants ~18–20 GB and minutes per turn. With a GPU, none of this is a conversation.

And if the model can think: turn thinking off. Ollama makes swapping a model a one-line change. It does not make the consequences a one-line change.

The pros, and the honest cons

Pros

  • Always-on, and it never bills a token. It watches while I don't. Read the cons before you call it free, though — it bills in CPU.
  • Privacy. The local model never sends a byte off the machine. For anyone whose logs or schema can't leave the building, this is the whole ballgame.
  • A smaller token bill, and better answers. Every call returns the answer instead of the raw material, so context stays dense. Cheaper and less wrong, from the same design.
  • The model becomes safe to swap. Because the fence is in the tools, I can change the local model without touching the safety posture. Note the word: safe, not free.
  • One audit trail, two models. Whoever acted, it's in the same ledger.
  • It makes you harden the tools properly, because now something less careful than Claude is holding them.

Cons, honestly

  • On a shared box, inference is the most expensive tenant. Not merely slow — unpredictable. The identical call took 29 s or 250 s depending purely on what the live systems left free, while eating ~7 of 12 cores. Fine for an async watchdog. Fatal for anything synchronous. Measure your own box before believing anyone, including me.
  • The model can be a trap. A thinking-enabled model behind tools on CPU may never return at all.
  • Local tool-calling is unreliable. Expect hallucinated tool names and malformed arguments. Plan for it — that's what the client-side gate is for — rather than being surprised by it.
  • It is not smart enough for judgment. It triages, diagnoses, and proposes. It does not decide. Every gate, every promotion, every merge is still mine.
  • More moving parts. Four units instead of one. Worth it only if you genuinely have something running 24/7 that you'd like watched.

The through-line

Part 4's rule was: the tool enforces the rule, not the prompt.

The corollary is why this setup works at all: if the enforcement is in the tools, the model is just a client. Interchangeable. You can hand a cheap, imperfect model the same hands and let it be wrong, because being wrong can't cost you anything.

But I very nearly shipped a second, sloppier corollary — therefore the model is free — and the only reason I didn't is that I measured it. It isn't free. On a box that's already working, it's the hungriest process on the machine, and its latency is a coin flip.

So the honest pair, which is what this post is really about:

The tools decide whether a model is safe to swap. The hardware decides whether it's worth swapping.

Get the first one right and you can afford to experiment freely. Get the second one wrong and you'll ship something that works perfectly in every respect except the one that matters.

That's how a 24/7 ops agent stops being an infrastructure project and becomes a systemd unit — and how a clever delegation tool becomes a config flag set to 0.


A companion to Part 4 and the hardening deep dive, part of Claude Code, Beyond the Prompt. The retrieval layer behind code_search and docs_search is open source: RE-call.

Top comments (0)