What it does
The models reach my docs, notes, files and homelab state through MCP servers I wrote myself: knowledge for docs and notes, files for my PDFs, homelab for the status of machines and services, and one that can only add new notes. Together they offer fifteen read tools and one write tool.
Each server sits behind the gateway, and each layer holds only the rights it needs. Search over docs and notes comes from the knowledge core; which models may use tools at all is decided in agent core.
Why this way
Decision 01
My own servers first, all behind the gateway
None of the servers has a published port. Clients reach them only through the gateway that also carries every model call, so permissions and logs live in one place. Each client key lists the servers and tools it may use, and a tool added by an upgrade isn’t enabled by itself. Public servers may come later, under written rules: pinned versions, a source review before adoption and on every upgrade, and nothing hosted for anything private.
Rejected: each client connecting to each server directly
Every client would need its own credentials for every server, and no single place would show who called what.
Decision 02
Least privilege in the credential, not in the code
Each layer holds only the rights it needs, so a bug in the code above it can’t do more. The files server never sees a folder; it reads views of content that was indexed and isn’t private, so it has no path to get wrong. The keys of my home media server never leave that machine: a small service there answers one read-only question, from a fixed list of fields, and only counts and states come out. Status from my virtualization host comes through a token that can read status and nothing else, and container status through a filter that answers the list and nothing more. The one write tool can only add a new note to the agents’ inbox, and the server picks its name.
Rejected: code that promises to only read, holding a key that can write
Whatever the code promises, the key could still write.
Decision 03
Tool output is untrusted
Every result reaches the model inside a fixed block marked as untrusted data, and a run that has read untrusted content gets no write tools. A note written by a run that read one of my documents is marked as tainted, and that mark comes from the run’s record, never from the model. My notes can’t load remote images when I open them, so an injected instruction can’t use one to send data out.
Rejected: letting the model pass the taint mark as a tool argument
A model that has been steered by injected text would simply leave it off.
Decision 04
Every server has a scope for evals
Each server has a second, separate scope that serves test data only: repo docs, a seed vault of made-up notes, test PDFs, recorded homelab answers, and a throwaway folder for written notes. Evals run the real server code but can’t see or touch my real notes, files or machines.
Rejected: running evals against the production scopes
The agent eval must never see my own notes, and a test write must never land in them.
How it's tested
Nothing planted leaks, and a narrow key stays narrow
Tests plant a title, a path and a fake key in the homelab fixtures, and none of them ever reaches tool output. In a live check, a key narrowed to one tool was offered only that tool, and the gateway refused the others; the eval key sees only the eval scope. Every tool call is its own run record, without the query text, because in an eval the query is a held-out question. Homelab questions scored 1.0 with tools and 1.0 without them, with no invented numbers either way.
A refused tool never costs the reply
One of my messages arrived just before the chat’s key got its tool permission. The tool connection failed, but the answer still came back from smart without tools, and the run record showed the failed step. That case is now a test: a refused key or tool never costs me the reply.
What I learned
A server name took the gateway down
When I added an eval scope for the knowledge server, the gateway refused to start: it doesn’t accept a hyphen in a server name. The mistake had come in with an earlier item and went unnoticed because the gateway wasn’t restarted then. Every chat was down for a few minutes. The lesson went into how I deploy: a config change counts as tested only once the service has restarted on it.
Case 01
A contract field the source couldn’t give
What happened
The homelab contract promised one more detail per container than the filtered status can provide.
What I changed
Rather than widen the filter, I dropped the field and keep a list of the containers I expect on each host.
Result
A missing or stopped container is a finding only if it’s on that list.
Case 02
A missing permission shouldn’t break the chat
What happened
One of my messages came in a moment before the chat’s key was allowed tools, so the tool connection failed.
What I changed
Every client is built so that a refusal degrades to an answer without tools, and the failed step is recorded.
Result
The reply still came, from smart without tools. A permission that arrived too late cost a tool, not the conversation.