MCP servers
Model Context Protocol servers contribute tools and resources Zuno does not ship. A server is either spawned locally as a child process or reached over HTTP. Either way its tools enter the same permission and authorization path as native tools; there is no second plugin-only permission language.
One default is worth knowing before you register anything: an unknown MCP tool is treated as side-effecting. It fails closed rather than being assumed safe.
Register a server
zuno mcp add my-server -- npx -y @modelcontextprotocol/server-filesystem /srv/data
zuno mcp add remote-server --url https://mcp.example.com --header "Authorization: Bearer $MCP_TOKEN"
zuno mcp list2
3
A local server's command goes after --. --env passes environment variables and --header passes HTTP headers.
Or configure it directly
The mcp map takes three shapes: a local server, a remote server, or a toggle for a server another configuration layer defined.
{
"mcp": {
"filesystem": {
"type": "local",
"command": ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/srv/data"],
"environment": { "LOG_LEVEL": "warn" },
"cwd": "/srv/data",
"enabled": true,
"timeout": 5000
},
"docs": {
"type": "remote",
"url": "https://mcp.example.com",
"headers": { "X-Tenant": "tenant-a" },
"timeout": 5000
},
"inherited-server": {
"enabled": false
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| Field | Applies to | Meaning |
|---|---|---|
type | both | local or remote |
command | local | Command and arguments, required |
cwd | local | Working directory; relative paths resolve from the workspace |
environment | local | Environment variables for the server process |
url | remote | Server URL, required |
headers | remote | Headers sent with every request |
oauth | remote | OAuth settings, or false to suppress auto-detection |
enabled | both | Start the server, or not |
timeout | both | Request timeout in milliseconds; the runtime default is 5000 |
The toggle form takes only enabled, which is how a project layer disables a globally defined server without repeating its definition.
A local server's command is a host executable, and the layer that declares it matters. Declared in a project zuno.json[c] or .zuno file, it is refused: discovery fails with a validation error naming mcp.<name>.command, because the checkout would be choosing a program that runs on this machine with your authority. "enabled": false beside the command does not change that: the switch is in the layer the checkout controls and any later layer can turn it back on without restating the command. A remote server runs nothing locally and is never refused, and the toggle form is how a project layer turns a globally defined server off.
To let a checkout keep its own local servers, name it in a trusted layer:
{
"trust": {
"project_host_commands": ["/home/you/src"]
}
}2
3
4
5
true trusts every checkout on this host, false is the default, and a list trusts the project config files inside those absolute roots. A project layer that sets trust itself is refused, so the grant always comes from the global zuno.json, ZUNO_CONFIG, ZUNO_CONFIG_CONTENT, the managed config directory, or macOS managed preferences. Admitted declarations are still logged one key at a time. Full precedence rules are in Files and precedence.
Authentication
For a remote server that needs OAuth:
zuno mcp auth remote-server
zuno mcp auth list
zuno mcp logout remote-server2
3
The OAuth block controls the flow:
{
"mcp": {
"docs": {
"type": "remote",
"url": "https://mcp.example.com",
"oauth": {
"clientId": "zuno-local",
"scope": "read:docs",
"callbackPort": 19876
}
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
| Field | Meaning |
|---|---|
clientId | Client id; absent means dynamic client registration (RFC 7591) |
clientSecret | Client secret, when the authorization server requires one |
scope | Scopes to request |
callbackPort | Local callback port; the runtime default is 19876 |
redirectUri | Full redirect URI, which overrides callbackPort |
Set "oauth": false to suppress auto-detection for a server that advertises OAuth metadata you do not want used. For a static token, a header is simpler than an OAuth flow, but keep the value in the environment rather than in the JSON file.
OAuth metadata documents are read with a 1 MiB bound, and what an oversized document means depends on who chose its address. A URL the server named in its own WWW-Authenticate challenge fails the login with an OAuth error, because the server chose both the address and the size. The .well-known paths Zuno derives from the configured URL are guesses, so an oversized answer there is logged with a warning and the next candidate is tried — a catch-all page on /.well-known/oauth-protected-resource cannot stop you logging in. An oversized document is never parsed on either path, and discovery that ends with no authorization server metadata at all still fails rather than guessing.
ACP session servers
An ACP client may supply a complete session-local mcpServers list on new/load/resume. Zuno supports stdio and Streamable HTTP there, not SSE. These servers are not written into zuno.json: they are validated process-local effects, all connect and discover before tools publish, and any partial startup is disposed in reverse order. Client commands, environment values, and headers are never stored in the session database or logs. See zuno acp.
How MCP tools become available
Registration is not authorization. Four things must hold for a model to call an MCP tool:
- The server is enabled and connected, or has a validated cached tool directory whose session-owned proxy connects successfully on first use.
- The tool's wire id survives the agent's exact
toolsallowlist, when one is configured. - No explicit permission rule denies it.
- For a delegated turn, the exact schema was visible in the parent Attempt.
Because unknown MCP operations default to side-effecting, granting one audited query tool does not opt an agent into every tool the server exposes:
{
"permission": {
"mode": "standard",
"rules": {
"docs_search": "allow",
"docs_write": "deny"
}
}
}2
3
4
5
6
7
8
9
MCP and extension tools are not automatically available to every read-only agent. A work-capable role may opt into automatic inheritance, and a read-only agent can be granted one audited tool id explicitly in its own rules, but the parent schema ceiling and the exact-schema check still apply. See Agents.
Direct and progressive schema exposure
Passing the four gates above makes an MCP tool executable; it does not require Zuno to inject every connected schema into every provider request. Default auto keeps small whole service catalogs visible on the first request: at most eight tools per service, within a shared automatic budget of 32 tools and 65,536 schema/description bytes. Larger catalogs stay behind tool_search, which advertises original service names and bounded capability summaries. The agent is instructed to choose relevant MCP tools proactively, without the user first naming a server.
tool_search searches ids, service names, display names, and descriptions. Matches are added cumulatively to the next provider step and cannot be called from the same assistant tool-call batch that discovered them.
This changes prompt exposure, not authorization: search cannot restore a denied tool or one removed by capability filtering. An exact Agent tools allowlist is treated as an intentional schema selection, so the MCP tools named there remain eagerly visible. A delegated child receives only exact schemas recorded in the parent Attempt and does not gain a broader catalog by searching.
Exposure is separate from connection configuration; changing it does not alter a transport identity, discard a validated directory cache, or create a global process pool:
{
"mcp_tool_exposure": {
"mode": "auto",
"auto_tool_limit": 32,
"auto_schema_bytes": 65536,
"small_server_tool_limit": 8,
"servers": {
"aws-knowledge-mcp-server": "eager",
"figma": "deferred"
}
}
}2
3
4
5
6
7
8
9
10
11
12
eager makes authorized schemas immediately visible; deferred uses discovery. Per-server values override the global mode. Explicit session/Agent pins remain eager outside the automatic budget; forcing everything eager can consume substantial context. Visibility never grants a forbidden tool.
Working native roles explicitly permit discovery before user overrides. If tool_search is disabled or denied, otherwise-authorized schemas stay direct rather than being hidden behind an unreachable loader. MCP denies still win.
Deferred does not mean disconnected, and enabled: true alone does not prove connected. Use visible tools directly and tool_search for deferred tools. Resource listing, extension listing and customize-zuno are not ordinary MCP-use prerequisites. Do not hand-write MCP HTTP in Shell to bypass discovery; explicit transport development or debugging is a separate task.
ACP session-local mcpServers are also an explicit client contract. Their schemas are present in the first provider request after the session's strict connection gate succeeds; host-configured MCP servers in the same session follow the exposure policy. This distinction follows the session across child and background turns.
The provider-request snapshot records the exact post-search tool schemas. Search results also record matched ids and the monotonic catalog revision in the durable tool result. Those completed results rebuild the same session's exposed subset after a detached wake, process restart, or client remount. Restoration is intersected with the current connected catalog and effective permissions, so it preserves continuity without reviving a removed capability.
Each subsequent tool call stores the provider-visible schema identity that admitted it. When retained history from an earlier turn is assembled, a missing or changed declaration is converted to an inert JSON transcript block for that request instead of being rebound to a different schema or sent as an invalid historical function call. Calls produced in the current turn stay native so their tool result can complete the protocol even when the dispatcher refuses an unknown call. Zuno emits a warning notice and keeps the durable row unchanged. If another registered tool already defines tool_search, Zuno does not shadow it: schemas stay eager for that turn and the host emits a warning.
The TUI adopts connected catalog changes at the next turn boundary, not mid-tool-call. debug agent reports schemaExposure with eager/deferred ids, policy, source metadata and discovery availability. It uses a separate diagnostic runtime; /mcp shows the mounted TUI's connection state.
Concurrency and timeouts
{
"concurrency": {
"mcp_connections": 8
}
}2
3
4
5
mcp_connections bounds simultaneous lifecycle operations across different servers. One server's operations stay serialized, which avoids a second connect racing its own disconnect. The field accepts 1..=64.
Request timeouts are per server: set timeout on the entry. There is no global MCP timeout key.
A failed MCP call is classified by what failed, not by the fact that it failed. A 5xx, 408, or 429 response and a dropped connection are recoverable, so the turn retries them with backoff. Every other 4xx — 400, 401, 403, 404, 405 — and an OAuth failure such as a server you set "oauth": false for blocks instead, because the identical request keeps failing until configuration changes.
A client-side deadline is treated as a third case, because the request may already have taken effect on the server. The remote tool proxy declares itself non-replayable, so a timed-out call returns a recovery note saying the side effect may have happened and must not be replayed until authoritative external state proves it did not complete. The two resource-listing tools declare themselves replay-safe, since resources/list mutates nothing.
An unreadable reply is the same third case. If a call that can change server state answers with a body Zuno cannot parse — cut short by a proxy, invalid JSON in an SSE event, or a stream that ends before the reply arrives — the write already reached the server, so the call reports the uncertain outcome rather than a definite failure, and the non-replayable policy still applies. Read-only methods such as tools/list and resources/read report the protocol error directly, because nothing on the server can have changed.
Stdio framing is bounded. One JSON-RPC frame may be up to 64 MiB, over four times the largest base64 resource blob a server can legitimately send. A longer frame fails every pending call with a protocol error and closes the stream, because a stream cut mid-frame cannot be resynchronised. Server stderr is bounded at 8 KiB per line and truncated rather than treated as fatal, since it has no pending caller.
Stdout that is not JSON-RPC is diagnosed rather than waited out. Lines that do not parse as a JSON-RPC message, blank lines included, are counted per connection; a server that produces 32 of them without ever framing one decodable message fails its pending calls with a protocol error naming the count, instead of sitting until the timeout with nothing to report, and the log records one warning per 1024 such lines rather than one per line. Once a server has framed a single message, later noise never disconnects it. A base64 resource blob or image block is measured after line breaks are removed, and the attachment Zuno stores holds exactly the bytes that were measured, so padding a payload cannot widen the 10 MiB decoded ceiling.
When tools do not appear
zuno mcp list
zuno mcp debug my-server
zuno debug agent build
zuno debug permissions2
3
4
mcp debug probes one registered server, which separates a server fault from a registration fault. debug agent shows whether the tool survived the agent's capability filtering, and debug permissions shows whether a rule denied it. Checking those two before suspecting the server saves the most time, because a tool hidden by an allowlist looks exactly like a server that failed to start.
MCP prompts
A server may also expose prompts, which become commands. Zuno asks the server for its prompt with every declared argument bound to the literal "$1", "$2", and so on, then fills those placeholders with your real arguments. See Workflows and commands.