MCP servers, and who may call them
Point dev3d at MCP servers, and their tools become tools your employees can hold.
dev3d is an MCP client. Point it at one or more Model Context Protocol servers and the tools those servers offer become tools your employees can hold — no plugin, no code change, no rebuild. This page is about configuring that, naming that, and the two places where the honest answer is "nothing stops it".
The capability is on by default and does nothing until you configure a server. DEV3D_MCP defaults to true, and the file that ships with the repository is { "servers": [] }. An installation with no MCP configuration is not a degraded installation; it is the normal one.
Two config sources, and the file wins
Servers come from either of two places and the two are merged, with the file winning on an id collision — it is the richer and more explicit source.
The inline variable
DEV3D_MCP_SERVERS takes <id>=<command> [args…] entries separated by semicolons:
DEV3D_MCP_SERVERS="fs=npx -y @modelcontextprotocol/server-filesystem /srv;git=uvx mcp-server-git"
A semicolon rather than a space is the whole point of the format. Arguments routinely contain spaces — and = — so anything whitespace-delimited cannot tell where one server ends and the next begins. The one thing this form cannot express is a path containing a semicolon; that server's arguments have to move to the JSON file.
The JSON file
DEV3D_MCP_CONFIG defaults to ./mcp.json, resolved against the repository root so the office reads the same file no matter which directory it was started from. An empty value disables the file entirely, which is how you run from the inline variable alone. A named file that does not exist is reported as a problem; a missing default mcp.json is simply "no MCP servers configured", because that is the shipped state.
The schema is one root object and a list:
{
"servers": [
{
"id": "fs",
"description": "Read-only filesystem helper",
"enabled": true,
"requestTimeoutMs": 30000,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv"],
"env": { "LOG_LEVEL": "warn" },
"cwd": "."
},
{
"id": "docs",
"type": "http",
"url": "https://mcp.example.com/mcp",
"headers": { "authorization": "Bearer …" }
}
]
}
Per server, the shared fields are id, optional description, optional enabled (default true) and optional requestTimeoutMs. The transport is then either stdio — command, optional args, optional env, optional cwd — or "type": "http" (or "sse") with an absolute http(s) url and optional headers. "transport" is accepted as an alias for type, and a server with no type at all is treated as stdio. Both id rules are enforced at load: the id must match ^[A-Za-z0-9][A-Za-z0-9-]*$, and a duplicate id inside the file is refused as servers[n]: duplicate server id "x". rather than silently overwriting.
Both transports, on Node built-ins
stdio — a child process speaking JSON-RPC over its own stdin and stdout — is how nearly every published server is used, and it is the default. Streamable HTTP is the other, with optional headers for whatever the far end wants. Both are implemented on Node's own primitives, so MCP support adds no dependency to the office.
The negotiated protocol version is 2025-06-18. Requests and the initial handshake each get a 30-second timeout, and tools/list follows nextCursor for up to 20 pages — a server with a lot of tools is paged through rather than silently truncated.
An id is letters, digits and dashes. Not underscores.
An id may contain letters, digits and -, and must start with a letter or digit. An underscore is refused outright, and the refusal says why:
MCP server id "x" must be letters, digits or "-", and start with a letter or digit (no "_", which is the tool-name separator)
The reason is the naming scheme. Every tool from a server is published as mcp__<server-id>__<tool-name>. Underscores inside the id would make that ambiguous: server a with tool b__c and server a__b with tool c would both publish mcp__a__b__c, and whichever server connected second would lose its tool — so which tool an employee got would depend on connect order.
Namespacing does two jobs at once. It makes collisions impossible between two servers that both offer search, and it means a server cannot shadow a built-in tool: a server offering read_file publishes mcp__<id>__read_file, and the built-in read_file is untouched. The published name is what an employee sees and what appears in the tool list.
Grants are default-deny
DEV3D_MCP_GRANT_ROLES defaults to none, so nobody may call an MCP tool until you name roles. Configuring a server does not make its tools reachable. The value takes role ids, *, or the legacy shell-roles / run_shell to follow the shell grant. The boot log states the effective policy in words — nobody — no role may call an MCP tool until DEV3D_MCP_GRANT_ROLES names one — so the state is visible without reading the config.
dev3d's own README lists the default here as shell-roles and is wrong. none is the code's default, and it is the one stale default in the project's documentation with security consequences, which is why it is called out in both places.
On top of the grant, the first call to a newly connected server asks a human. DEV3D_MCP_REQUIRE_APPROVAL defaults to true; the approval is kind network, it fires once per server per connection, and reconnecting asks again. A refusal is not a silent failure — the model is told in words not to try again:
The human declined to use the MCP server "<id>", so "<tool>" was not called. Do not retry it; carry on with the tools you have.
Turning the approval off produces its own boot line rather than passing silently: MCP approval is OFF (DEV3D_MCP_REQUIRE_APPROVAL=false) — calls run unattended.
MCP tools are not confined
This is the sentence the system prompt itself carries: MCP tool arguments go to the server's own process verbatim, and ctx.workspaceRoot is never consulted. There is no path check on the way in, because there is no path for dev3d to check — the server is a different process that may open whatever it likes, and the argument it receives is the argument the model produced.
That is a deliberate trade rather than an oversight, and the reasoning is the same as the one behind plugin tools. What makes an MCP server worth attaching is that it reaches something the office cannot: a database, a ticket system, a hosted service, a filesystem the workspace root does not contain. An MCP tool whose every argument was confined to the run's directory would be a worse version of a built-in tool. So the office does not pretend to confine it, and instead puts its controls where they actually bind — the grant policy and the approval prompt — and says plainly in the prompt that the confinement is not there.
The comparison worth holding in mind is Workspace and tools: eight of the fifteen built-in tools funnel their path arguments through resolveInWorkspace, and run_shell and git do not. An MCP tool sits with the second group, and unlike run_shell it is not approval-gated on every call.
A server that is down is not a failure of the office
Connections are made in the background, after the listener is open. A server that fails to connect is recorded with its state and its reason — including recent stderr — and the others keep working. The office starts, employees get the tools from the servers that answered, and the console shows what did not. A broken MCP server is a broken dependency, not a broken installation.
What dev3d does not do is reconnect on its own. There is no automatic reconnect. A server that was down at boot, or that died afterwards, stays down until something retries, and the retry is explicit: POST /api/mcp/refresh, or Settings → MCP → Reload servers, which re-reads the config and retries every server. Removing a server from the configuration disconnects it and withdraws its tools.
The console surface, and what else is scrubbed
MCP is a Settings sub-tab, not a top-level tab. Settings has six sub-tabs — General, Models, Skills, Budget, Safety, MCP — and the MCP tab is read-only by design. It shows the configured servers, their state and their reason, and it carries the Reload servers button. You edit mcp.json or the environment variable; you do not edit servers in the UI.
A stdio MCP server is a spawned child process, so it goes through the same environment scrub as every other spawn in the codebase. Credential-named variables — the _API_KEY, _TOKEN, _SECRET, _PASSWORD families and the exact names like AWS_ACCESS_KEY_ID, GH_TOKEN and NPM_TOKEN — are stripped before the child starts. A server that genuinely needs a credential gets it from the env block in its own entry, which is an explicit decision you wrote down.
The limits, and the open ones
| Limit | Value |
|---|---|
| A tool result handed back to the model | capped at 20 000 characters |
| A tool description | capped at 1 000 characters |
| A tool's input schema | capped at 8 000 characters |
| A request, and the handshake | 30 s each |
tools/list paging | up to 20 pages of nextCursor |
| Protocol version | 2025-06-18 |
| stdio line with no newline in it | 8 MB, then discarded and reported |
| HTTP response body | 8 MB, above which the body is refused rather than buffered |
Three of those are worth a sentence more than a row. The 8 MB stdio bound is on a single line: a peer that never sends a newline cannot grow the buffer without limit, and when the bound trips the bytes are discarded and reported rather than dropped quietly, because a silent discard leaves an operator with a server whose replies vanish and no reason why. The description and schema caps exist because those strings go into every prompt that offers the tool, so an unbounded one is a token bill the operator never agreed to. And the 30-second request timeout is a per-server default you can raise with requestTimeoutMs on that server's entry.
Beyond the numbers, three things are open rather than solved:
- There is no stdio command allow-list. Any
commandin the config will be spawned. The gate is that a human wrote the config, and this is recorded as a deliberate choice rather than a missing feature. - There is no automatic reconnect. Covered above, and worth repeating because a server that quietly went away reads exactly like a server that never existed until you open the MCP tab.
- Nothing about an MCP tool is confined or sandboxed. The grant policy decides who may call it; it decides nothing about what the call does. Treat a configured server with the same trust you would give a plugin, because that is the level of trust the design assumes.
The rest of the security picture — what is confined, what is gated, and what is neither — is on Known gaps and Workspace and tools. If you want to give an agent a capability that is not a separate process, Building a plugin is the other route, and it comes with the same "not confined, and it runs in the office's own process" caveat stated at the top.
Did this page answer your question?