Skip to content
Neal Gysemanslocal-agent-mcp

local-agent-mcp

An MCP server that lets Claude Code hand work to a model on your own machine — and a sandbox tight enough that letting it is reasonable.

Role
Sole author — design, implementation, tests
Year
2026
Status
Not yet released
Stack
Python · MCP · Ollama
Architecture diagram: Claude Code hands a task to the local-agent-mcp server, which runs an Ollama model on your own machine behind three guardrails — path resolution, a three-turn cap, and a command allowlist.

The constraint

Cloud tokens cost money, and some code should not leave the machine. A model running locally solves both — but handing an autonomous loop a filesystem and a shell is how you lose an afternoon, or a directory.

So the design question was never the plumbing. It was blast radius. Three decisions follow from that, and each one cost something.

Resolve the path, don’t compare the string

Every path goes through resolve_within, which realpaths both the base and the candidate before comparing them with commonpath. Symlinks and .. are resolved before the check rather than after, and an absolute path escapes the join rather than defeating the guard.

What it cost: slower than a prefix comparison, and every call can now fail for a reason the caller has to handle. In exchange the obvious bypasses don’t work — ../../../etc/passwd, an absolute /etc/passwd, and sub/../../out.txt are all refused, and there are tests saying so.

A turn cap, not a stop condition

The agent loop is hard-capped at three turns. Control returns to the caller whether or not the local model believes it has finished.

What it cost: a task that genuinely needs a fourth turn fails, and the caller has to split it. Worth it — a model that can’t tell it’s stuck can’t be trusted to decide when to stop.

The allowlist is a guardrail, and it says so

Commands are parsed rather than handed to a shell, and matched on the program rather than the raw string. The documentation now states plainly what that protects against: a local model making a mistake, not an adversarial one.

What it cost: no pipes, no redirects. And it cost me the embarrassment of finding the first version wrong. That version glob-matched the whole command string under shell=True, so a pattern as tight as git * still let git status; echo PWNED through — both halves ran. The README claimed the mitigation was “scope patterns tightly”, which was advice that could not work.

What I’d do differently

I wrote the safety documentation before I tested it, and it was wrong for months. The lesson isn’t “write tests” — there were tests, including a test_safety.py. It’s that a claim in a README deserves a test of its own. The tests covered what the code did. Nothing checked whether the code did what the documentation promised.