BLOG

🌱 digital garden

Based in Nice, France - CET (UTC+1)

BLOG

🌱 digital garden

Based in Nice, France - CET (UTC+1)

BLOG

🌱 digital garden

Based in Nice, France - CET (UTC+1)

Context engineering lessons from an MCP server

I put a penetration-testing toolkit behind the Model Context Protocol. The protocol was the easy part. Deciding what the model should be allowed to see was the engineering.

pepperspray-mcp is an MCP server that puts a penetration-testing toolkit (nmap, nuclei, sqlmap, trivy and many more) behind an AI assistant, for authorized testing only. It registers 151 tools. Getting them registered took a few days. Understanding why 151 is the wrong number took longer, and it is the more transferable lesson.

A tool schema is a prompt

The model never sees your implementation. It sees a name, a description and a parameter list, and by default it sees them in every single conversation, whether or not the tool gets used. A tool definition is not only an API contract. It is prompt text you pay for on every request, and it competes for attention with the user's actual question.

I did not think of it that way when I registered them. With a handful of tools this does not matter. With 151, the catalogue is the largest thing in the context before the user has typed a word, and selection quality drops in a predictable way: the model picks nmap_scan when nmap_advanced_scan was right, or dirb_scan when gobuster_scan and ffuf_scan do the same job. Three tools that differ in ways only an expert cares about are, to a model, one tool with a coin flip attached. Some clients now load tool definitions on demand, through a search step. That fixes the cost. It does not fix the choice: a search over 151 descriptions still returns three candidates that look the same.

The escape hatch that swallows the schema

Here is a typical signature:

def nmap_scan(target: str, scan_type: str = "-sV",
              ports: str = "", additional_args: str = "") -> Dict[str, Any]:
def nmap_scan(target: str, scan_type: str = "-sV",
              ports: str = "", additional_args: str = "") -> Dict[str, Any]:

additional_args appears 258 times in the file. It is there for a good reason: no schema can enumerate every flag of every security tool. It also quietly undoes the schema. Once a free-form string reaches the command line, the typed parameters are suggestions, and the model learns that the fastest route to any behaviour is to write flags into the one field that accepts anything.

That is a correctness problem and a safety problem at once. Typed parameters can be validated. A string of flags can only be trusted or parsed, and parsing other people's CLI grammars is a project of its own. If I were starting again I would make the trade explicit: a small set of narrow, validated tools for the common path, and one clearly named raw tool for everything else, so that the dangerous door is a single door with a sign on it.

Results are context too

A full nmap run against a /24 produces output no human reads end to end. Handing all of it back to the model is the same mistake as the oversized catalogue, at the other end of the call. The tool result should be the answer to the question the model asked (which ports are open, which services, which versions), with the raw output written to a file the model can ask for.

The rule I use now for any tool: return what a senior colleague would say out loud, and keep the log on disk.

The container is the permission system

Everything executes inside Docker. The assistant talks to a thin wrapper script, which talks to a bridge, which talks to the server in the container:

MCP client ── stdio ──► wrapper.sh ──► TCP ──► tcp_stdio_bridge.py ──► server (Docker)
MCP client ── stdio ──► wrapper.sh ──► TCP ──► tcp_stdio_bridge.py ──► server (Docker)

Most MCP clients launch a local process and speak over its stdin and stdout. This server stays up in a long-running container and listens on a socket. The bridge is 124 lines that forward bytes in both directions and spawn one server process per connection. It is not interesting code, and that is the point: the protocol adapter should be the dullest part of the system.

What the container buys is a boundary you can reason about. A model with a tool that runs shell commands is a remote shell with a polite interface. I cannot prove the model will never be talked into something by a hostile web page it just scanned. I can make sure that when it happens, the blast radius is a disposable container with no credentials in it and not my laptop. The network is the part a container does not solve. A scanner has to reach its targets, so the scope of a test still has to be enforced somewhere else.

Timeouts are a design decision

The default request timeout is 30 minutes, because real scans take that long. A model waiting 30 minutes on one call is a model doing nothing, and a client that gives up after five leaves a scan running with nobody listening. Long work wants the pattern every job queue already uses: start returns an id at once, status is a separate cheap call, and the result is fetched when it is ready. Request and response is the wrong shape for anything a human would go and get coffee during.

What I would build now

Not 151 tools. I have not built this version yet, so read it as a plan and not a result. Something closer to this:

  1. Ten to fifteen tools the model can tell apart, each with one job and typed parameters that are actually enforced.

  2. One raw escape hatch, named so nobody mistakes it for the safe path, and logged.

  3. Summaries in, logs on disk. The model gets conclusions and a path.

  4. Jobs, not calls, for anything slower than a few seconds.

  5. The sandbox as the real permission system, because a prompt is not a security boundary.

The phrase "context engineering" gets used loosely. In practice it came down to one question asked at every boundary: what is the least this model needs to see to make the next decision well? Every time I answered it honestly, the answer was less than what I was sending.A note from laterA note from later

A note from later. Since I wrote this, the Claude line has grown and more models have been announced. I have run pepperspray-mcp across several of them, from Opus down to the smaller chat models, and the tool does not change between them. That is the point of the design above: the model decides which tool to call, and the container decides what the call may do. Because the safety boundary is the sandbox and not the model, the toolkit runs just as well on a lighter model without becoming more dangerous. What carries the results is not the size of the model but the method around it, a clear scope, the right skill driving the session, summaries in and logs on disk. Given that, it works very well. The permission system was never in the prompt.

This article was co-authored with the help of AI.