# MCP servers https://fenzi.dev/docs/mcp List your MCP servers once in `fenzi.yaml` and every agent gets them: Claude Code, Codex, OpenCode, and Grok, each in its own config format. Inside the sandbox, every call to a remote server is checked against the tools you listed for it. A server's token, and the secret a local server reads, stay outside the sandbox, the way a model key does. Checked per tool by the sandbox ยท Tokens stay on your machine ## Add a server ```bash fenzi mcp add deepwiki https://mcp.deepwiki.com/mcp --tool read_wiki_structure --tool read_wiki_contents ``` Name the server, give its URL, and list the tools agents may call, one `--tool` each. `--all-tools` allows every tool the server has, and `--deny` refuses one even then. The entry goes into the `mcp` section of `./fenzi.yaml`: fenzi.yaml ```yaml version: 1 mcp: deepwiki: url: https://mcp.deepwiki.com/mcp tools: allow: [read_wiki_structure, read_wiki_contents] linear: url: https://mcp.linear.app/mcp token: true tools: allow: [list_issues, get_issue, create_comment] deny: [delete_issue] files: command: [npx, -y, "@modelcontextprotocol/server-filesystem", /sandbox] ``` The next `fenzi run` gives the servers to the agent. Agents read their MCP servers when they start, so a change applies to a new sandbox, not to one already running. ## A server that needs a token ```bash fenzi mcp token linear ``` Paste the token at the prompt; it is not shown. Fenzi stores it in the sandbox runtime on this machine, never in a file. The server's entry says `token: true`; `fenzi mcp add --token` writes that. Inside the sandbox the agent holds a placeholder, and the runtime puts the real token only on requests to that server. Sent anywhere else, it stays a placeholder. Servers that sign you in with OAuth, such as Linear, GitHub, or Notion, can also take a personal API token this way. If the agent runs the server's OAuth sign-in itself, that token is kept in the agent's own files inside the sandbox, where the agent can read it. ## Claude's account connectors ```bash fenzi mcp add claude-ai --claude-account --tool list_teams --tool list_issues --tool get_issue ``` The connectors you added on claude.ai, such as Linear or Apollo.io, come with your Claude sign-in. Claude Code finds them itself and reaches all of them through one Anthropic host, with its own sign-in. Without this entry the sandbox refuses that host, and Claude Code reports `ECONNREFUSED` for each connector. With it, the sandbox checks every call against the tools you list, and `fenzi log` names each one: fenzi.yaml ```yaml mcp: claude-ai: account: claude tools: allow: [list_teams, list_issues, get_issue] deny: [save_issue] ``` Name the tools as the connector does: `list_issues`, not `mcp__claude_ai_Linear__list_issues`. All connectors share the host, so an allowed name is allowed on every connector that has a tool by that name. There is no token to store. Connectors come with a Claude subscription sign-in, `fenzi run --sign-in claude`; with an API key Claude Code has none. Only Claude Code gets the entry. The other servers in `fenzi.yaml` stay. With the entry, Claude Code also reads a project's own `.mcp.json` and asks you before it uses those servers; the sandbox's rules still apply to them. ## What the sandbox checks Each remote server gets a rule the sandbox enforces on every request: the methods a client sends to connect and list what the server offers, the tools you allowed, and the tools you denied. Anything else is refused with HTTP 403 `policy_denied`. Only the agent's own program reaches the server. fenzi log, excerpt ``` 13:33:38 allowed POST mcp.deepwiki.com/mcp tools/list 13:33:48 allowed POST mcp.deepwiki.com/mcp tools/call read_wiki_structure 13:33:50 denied POST mcp.deepwiki.com/mcp tools/call read_wiki_contents ``` The log names the tool of every call. The agent's error does not: it says `POST /mcp not permitted by policy`. Some requests are refused on every connection and are routine: Claude Code and Grok first try `server/discover`, then connect the usual way, and clients close a session with a `DELETE` the rule does not allow. The policy check before start does not cover MCP rules, or the hosts a secret is bound to: the policy prover cannot read them. Fenzi checks them itself, and `fenzi run` says so on its `mcp` and `secrets` lines. ## Local servers ```bash fenzi mcp add files -- npx -y @modelcontextprotocol/server-filesystem /sandbox ``` A server with a command runs inside the sandbox as a child of the agent, with the agent's limits on files, processes, and network. Its traffic with the agent is a pipe, so it is not checked per tool. List the hosts it calls with `--host`, or add them to the policy's `hosts`. A server that has to run on your machine, outside the sandbox, is not supported. The sandbox cannot install an npm package whose name starts with `@` yet, which includes the servers in the examples on this page: the npm registry rule refuses the request npm makes for it. Until that is fixed, such a server runs only when its package is already in the image or in the project copy. ## A local server that needs a secret ```bash fenzi mcp add droplets --secret DIGITALOCEAN_API_TOKEN --host api.digitalocean.com -- npx -y @digitalocean/mcp --services droplets ``` Some local servers read an API token from a variable. DigitalOcean's reads `DIGITALOCEAN_API_TOKEN`. `--secret` names the variable, and `--host` names the hosts the server sends it to. The entry holds the names, never the value: fenzi.yaml ```yaml mcp: droplets: command: [npx, -y, "@digitalocean/mcp", --services, droplets] secrets: [DIGITALOCEAN_API_TOKEN] hosts: [api.digitalocean.com] ``` ```bash fenzi mcp token droplets ``` Paste the value at the prompt; it is not shown. Fenzi stores it once, in the sandbox runtime on this machine, and every server that lists the same variable uses it. Inside the sandbox the variable holds a placeholder. The runtime puts the real value only on requests to the hosts listed with it. Sent to any other host, the request is refused with HTTP 403 `credential_endpoint_mismatch`. The placeholder is there for every program the agent starts, not only the server. Any of them can use the secret on those hosts, with whatever it allows there, so give it the narrowest scope the service offers. Claude Code and Codex get these servers today. OpenCode and Grok refuse them until they are tested. ## For every project, or one `fenzi mcp add --machine` writes to `~/.config/fenzi/mcp.yaml`, for the servers you use in every project. A project's `fenzi.yaml` adds its own and can narrow one the machine file has: fewer tools, more denied. It cannot widen it. On a machine signed in to a team, the servers come from the team's policy. ```bash fenzi mcp list ``` Lists the servers agents get in this folder, where each one comes from, and whether its token is stored. ## Import from .mcp.json ```bash fenzi mcp import ``` Copies the servers in `.mcp.json` or `.cursor/mcp.json` into `fenzi.yaml`. `--dry-run` shows what it would add and leave out, and why, and writes nothing; the Mac app shows the same preview before it imports. Remote servers come in with every tool allowed, so narrow each one afterwards. A token in a header is not copied; store it with `fenzi mcp token`. A local server that reads a secret comes in with the variable's name under `secrets`, never its value, when Fenzi knows which hosts the secret goes to. Today that is `DIGITALOCEAN_API_TOKEN`, sent to `api.digitalocean.com`; DigitalOcean's other services may call more hosts, which the sandbox refuses and `fenzi log` shows. A server with another secret is left out, with the `fenzi mcp add` line to write. Each other server the policy cannot take is left out with the reason, and the rest come in. Inside the sandbox the agent uses Fenzi's list and ignores its own files, so what it can call is what the policy says. ```bash fenzi mcp import --dry-run ``` Shows the servers an import would add, the ones it would leave because `fenzi.yaml` already has them, and the tokens and secrets it would not copy. It writes nothing. With `--json` it prints the same as one document. ## When the agent asks for a tool An agent may file a request for an MCP server through the sandbox, or for the host of Claude's connectors. Fenzi does not approve it: in OpenShell 0.1.2 an approved MCP request allows every tool on that server, not the one it named ([NVIDIA/OpenShell#4679](https://github.com/NVIDIA/OpenShell/issues/4679)). `fenzi approve` says so, and a Team approval in the console is turned into a rejection with that reason. Add the tool to the server's entry instead and start a new run. ## Limits - An allowed tool gets any arguments the server accepts. - What a server sends back is not checked. A tool result can carry instructions to the agent; the sandbox limits what the agent can then do, not what it reads. - Only servers over Streamable HTTP get per-tool rules. - Claude's account connectors are one entry: a tool is allowed by name, not per connector. - One server per host and port, and an MCP server cannot share its host and port with a host in `hosts`. - A host carries one secret. Two local servers that send different secrets to the same host cannot both be listed. - npm packages whose name starts with `@` do not install inside the sandbox yet. - Tested end to end with Claude Code: an allowed tool call went through and a tool not listed was refused. Codex connected and listed the server's tools in a sandbox; a tool call from Codex has not been run yet. OpenCode and Grok get the same servers in their own config formats; their runs in a sandbox are next. A local server's secret was tested with Claude Code in a sandbox, with a test value and a server that echoes the request: the server held a placeholder, and the value reached the listed host only. Codex was checked outside a sandbox.