For MCP builders
For people building MCP servers
An MCP server is a process, not a library. Almost every design question follows from that one fact: who owns the process, whose credentials it runs under, and which tools should not exist until someone can confirm them.
CompanyBrain ships a small server that answers all three, and it is short enough to read in one sitting.
A server that does the minimum
The graph-rag server registers two tools: search, and list the sources this user may read. It takes its credential from the environment and refuses to start work without one. A comment explains the contract the caller has to honour: the process is scoped to a single user session, so the gateway must launch one process per session and inject that user's token.
That refusal is the useful part. A server that silently falls back to an empty credential is a server that returns someone else's documents in a misconfigured deployment.
Notice what is absent: no caching layer, no retry policy, no fallback credential. The server does one thing and fails loudly, which is the correct default for a component that another process supervises.
A token from the environment, a process-scoped user, and two tools. No fallback when the token is missing.
TOKEN_ENV = "GRAPH_RAG_MCP_TOKEN"
mcp = FastMCP("graph-rag")
def resolve_user_context() -> UserContext:
# Stdio MCP is process scoped. The Agent Gateway should launch one module MCP
# process per user session and inject the user's bearer token via TOKEN_ENV.
token = os.getenv(TOKEN_ENV)
if not token:
raise RuntimeError(f"{TOKEN_ENV} is not configured")
return UserContext(
user_id=os.getenv("FF_USER_ID", ""),
username=os.getenv("FF_USERNAME", ""),
is_admin=os.getenv("FF_IS_ADMIN") == "true",
)
@mcp.tool()
async def graph_search(query: str, limit: int = 10, source_id: str | None = None, mode: str = "mix") -> dict:
"""Search GraphRAG and return evidence/context for the Agent to answer from."""
return await search(resolve_user_context(), query, limit, source_id=source_id, mode=mode)One server per module, one process per session
The gateway declares three module servers by name, and keeps a separate allowlist of tools for each. That is the first decision: a server owns one module's tools, so permissions and credentials have exactly one place to live.
The second decision follows from stdio. Because the connection is process-scoped, the gateway builds a launch plan per agent session: the module id, the command, and an environment carrying the user's bearer token and identity. The comment names both the reason and the way out if a module ever supports streamable HTTP with per-request authentication.
The cost is honest: process count grows with concurrent sessions. The benefit is that two users can never share an MCP connection with one token.
The allowlist is worth reading in full, because it shows what a per-server boundary buys you: a user tool set and an admin tool set can live in the same process without the model ever seeing the second one.
One server name per module. The allowlist below it is per server, not per agent.
export const NANO_BRAIN_MCP_SERVER_NAME = 'nano';
export const TRADITIONAL_RAG_MCP_SERVER_NAME = 'traditional';
export const GRAPH_RAG_MCP_SERVER_NAME = process.env.GRAPH_RAG_MCP_SERVER_NAME ?? 'graph';Leave the dangerous tool out of the registry
The gateway keeps a high-risk set that is not exposed to the agent at all. The comment above the one entry says the tool stays out until it gains a confirmation token.
This is cheaper and safer than a prompt that asks the model to be careful, and it is visible in one place. If you are building a server with a destructive tool, the decision to make is not how to describe it but whether it should be registered before a confirmation path exists.
Withheld rather than discouraged.
const HIGH_RISK_NANO_TOOLS = new Set([
// Not exposed by default before M30; must gain a confirmation token first.
'nano_admin_review_fact_submission',
]);Where to list it
Directories such as mcp.so accept community servers and are where people look before they write one. Listing is distribution, not design: a directory entry will not fix a server that leaks another user's data, and a good one will not save a server nobody can install.
What tends to matter in a listing is the same thing that matters in the code: what the server reads, whose credential it uses, and whether the install is one command.
Before listing, install your own server the way a stranger would: a clean machine, the documented command, no local state. Most listings fail at that step, and the fix is documentation rather than a better pitch.
A checklist before you register a tool
Four questions, all answerable from the server code rather than from its description.
Whose credential does the process run under, and what happens when it is missing. What does the tool read or write, in terms the caller can verify afterwards. Can the tool be called twice without a second side effect. And if a person has to confirm it, where does that confirmation live today.
The CompanyBrain server answers the first by refusing to run without a token, the second by returning evidence with citations, and the fourth by not registering the review tool at all. The third is the one you have to design yourself.
When not to write an MCP server
If the goal is to let an assistant read a handful of files on one machine, a CLI is simpler and has fewer failure modes. MCP adds a process to supervise, a transport to debug and a credential story to get right.
Write a server when several clients need the same tools, when the tools need a per-user permission boundary, or when someone else will install it. If none of those is true this week, a shell script that prints what you need is the honest implementation.