MCP (Model Context Protocol)
Connects Codumentor to MCP servers and exposes their tools to the agent. Each plugin entry is one server: a local stdio process or a remote Streamable HTTP endpoint, never both. Add several plugins: entries to attach several servers.
The plugin is bundled with Codumentor; no extra install is required beyond the MCP server itself.
MCP Apps web UI
The web conversation host implements the stable MCP Apps extension
io.modelcontextprotocol/ui (2026-01-26). A tool whose metadata points to a
ui:// HTML resource renders an interactive view alongside its ordinary text
result. The TUI, exports, shared links, and admin viewer remain text-only.
Codumentor keeps the MCP connection and credentials on the backend. An app may
read resources and call app-visible tools only on the server that created its
instance. Calls without an explicit readOnlyHint: true use Codumentor's native
approval dialog. App messages require a native confirmation, and app model
context is labeled and included only on later turns.
Operators must configure the dedicated cross-origin sandbox described under
api.mcp_apps. Once that origin is
valid, Apps support is advertised automatically; api.mcp_apps.enabled: false
is the rollout kill switch. If a resource, server, CSP, browser initialization,
or deployment gate fails, the normal textual tool card remains usable.
Server authors should provide all of the following:
- meaningful text content in every tool result;
- nested
_meta.ui.resourceUriwith an exactui://URI; - a single text resource with MIME type
text/html;profile=mcp-app; _meta.ui.visibilityfor model-only or app-only tools;- explicit
readOnlyHint: trueonly for genuinely side-effect-free tools; and - the smallest possible
_meta.ui.cspdomain lists.
Deprecated _meta["ui/resourceUri"] metadata is accepted for migration. Draft
features, picture-in-picture, direct server transports from the browser, and
optional browser permissions are not supported.
Protocol revisions
The protocol comes from the official mcp Python SDK,
which speaks the current revision (2026-07-28: stateless, no sessions, no
initialize handshake) and the handshake era before it. The era is detected
per server, not configured: the client probes server/discover once and drops to
initialize if the server does not know it. The log line at startup names the
revision that was negotiated.
Not supported: the 2024-11-05 HTTP+SSE transport, where the client opens a
GET stream and is handed a POST endpoint. It has been deprecated since
2025-03-26, and a server still on it needs the SDK's sse_client wired in
place of streamable_http_client in mcp_client._build_transport.
Configuration
Local stdio server
plugins:
- module: codumentor.plugins.mcp
class: MCPPlugin
args:
executable: "mcp-filesystem" # required for stdio
args: [] # optional argv
env: {} # optional process environment
enabled: true
target_agents: "all"
write_target_agents: "main"
disabled_tools: []
response_truncate_length: 30000
watch_tool_changes: true # see "Keeping the tool list current"
sandbox: # see "Sandboxing" below
mode: auto
Remote server (Streamable HTTP)
plugins:
- module: codumentor.plugins.mcp
class: MCPPlugin
args:
url: "https://mcp-server.example.com" # required for remote
headers:
# Literal token, shared by everyone. For a per-user token write
# "Bearer ${secret:my_service_token}" — see "Per-user secrets" below.
Authorization: "Bearer your-token"
oauth: false # true = each user authorizes in their
# own browser; see "OAuth" below
enabled: true
target_agents: "all"
write_target_agents: "main"
disabled_tools: []
response_truncate_length: 30000
watch_tool_changes: true
Exactly one of executable or url must be set.
Parameters
Stdio only
- executable (required): Path or name of the MCP server executable.
- args (optional): Command-line arguments, e.g.
["--workspace", "/path/to/workspace"]. - env (optional): Environment variables for the process, e.g.
{"API_KEY": "your-key"}. - sandbox (optional): How the server process is confined. See Sandboxing.
Remote only
- url (required): MCP server URL.
- headers (optional): HTTP headers (typically authentication). For a token that differs per user, write
${secret:<name>}instead of the literal value — see Per-user secrets. - oauth (optional, default
false): Let each user authorize this server in their own browser instead of pasting a token.oauth: trueis the whole of the usual configuration. See OAuth.
Both
- enabled (optional, default
true). - target_agents (optional, default
"all"): who gets this server's tools — an agent kind ("main","subagent","all"), a role naming one subagent ("explore","agentic_developer", …), or a list of either meaning "any of". - write_target_agents (optional, default
"main"): the same vocabulary, applied to this server's write tools only. See Who gets which half. - disabled_tools (optional): Tool name or list of names from that server to hide, e.g.
"read_file"or["read_file", "write_file"]. - response_truncate_length (optional, default
30000): Maximum characters of a tool result returned to the agent. Larger payloads are truncated with a notice. - watch_tool_changes (optional, default
true): Follow the server's tool list for as long as the connection lives. See Keeping the tool list current.
Who gets which half
Two gates, not one. target_agents (default all) decides whether this server's tools are offered at all; write_target_agents (default main) then decides who gets the write half.
Which tools are writes is the server's own answer, not Codumentor's. Codumentor reads the readOnlyHint annotation each tool carries in the server's tools/list response:
| What the server says | Treated as |
|---|---|
readOnlyHint: true | a read — offered wherever target_agents admits |
readOnlyHint: false | a write — offered only where write_target_agents admits |
| no annotation at all | a write |
Silence counts as a write because that is how the MCP specification itself reads it: readOnlyHint defaults to false and destructiveHint to true, so a tool that says nothing is one that may change things. The practical consequence is worth knowing before you debug it: a server that annotates none of its tools is invisible to subagents. The main agent is unaffected.
The startup log line says which case you are in, once per server:
MCP client started successfully with 14 tools; 9 annotated readOnlyHint,
5 counted as writes (the protocol's default for an absent hint) and
offered only to write_target_agents=main
If you trust an unannotated server and want subagents to have it, set write_target_agents: all on that entry — one plugin entry is one server, so the knob is already per-server. If you want the reverse, target_agents: main keeps the whole server off subagents.
Treat this as hygiene over cooperative servers, not a security boundary: the annotation is the server's own claim, so a server that mislabels a destructive tool as read-only hands it back to every agent. Only run stdio executables and remote endpoints you trust.
Multiple servers
plugins:
- module: codumentor.plugins.mcp
class: MCPPlugin
args:
executable: "mcp-filesystem"
- module: codumentor.plugins.mcp
class: MCPPlugin
args:
url: "https://remote-mcp.example.com"
headers:
Authorization: "Bearer your-token"
Sandboxing
A stdio MCP server is a third-party binary that receives LLM-authored
arguments on every tool call, so it is confined the same way the agent's own
shell is: in a bubblewrap sandbox.
Remote (url) servers spawn no process and ignore this section.
sandbox:
mode: auto # auto | on | off
network: true # share the host network namespace
workspace: ro # ro | rw | none — how the repos appear
ro_binds: [] # extra read-only binds, "HOST" or "HOST:SANDBOX"
rw_binds: [] # extra read-write binds
env_pass: [] # host env var names to forward by name
- mode (default
auto):autosandboxes the server whenever theworkspace_isolationplugin is loaded and enabled, and runs it host-direct otherwise — the same rule external coding agents and the Office document workers follow.onalways sandboxes.offis the documented exception for a server that genuinely cannot be confined. - network (default
true): most MCP servers exist to reach a remote API, so unlike the agent's shell the network namespace is shared by default. Setfalsefor a server that only needs local data. - workspace (default
ro): mounts the repos at/workspaceread-only.rwlets the server modify the real working tree;nonehides it entirely. - ro_binds
/rw_binds: extra host paths."/opt/tool"mounts at the same path inside;"/opt/tool:/data"remaps it. Applied in order, so a nested mount can follow the directory it sits inside. - env_pass: names of host environment variables to forward. Empty by default — the server sees only what
env:sets plus the sandbox baseline.
Inside the sandbox the server gets a private, persistent $HOME (under
~/.codumentor/mcp-homes/<server>/), so tokens and package caches survive a
restart while the real home — ~/.ssh, ~/.gnupg, everything else — is
invisible. It runs in its own PID namespace, so it cannot signal the
Codumentor process, and dies with it. The seccomp filter configured on
workspace_isolation applies here too.
What sandboxing changes for an existing server. The working directory
becomes /workspace, not Codumentor's, so relative paths in args: stop
resolving — use absolute paths. Anything the server reads outside the repos
(a config file, a data directory) needs an explicit ro_binds entry.
executable: "/opt/mcp/bin/server"
args: ["--config", "/etc/mcp/server.toml"]
sandbox:
network: false
ro_binds: ["/etc/mcp"]
rw_binds: ["/var/cache/mcp"]
If a server must be sandboxed and cannot be — bubblewrap is not installed, a
bind path does not exist, the seccomp blob will not load — it **fails to
start** rather than quietly falling back to the host. The log line says which
of those it was. Set mode: off to accept running it unconfined.
Per-user secrets
Use ${secret:<name>} in remote headers so each caller authenticates with their own key. The value is taken from that user's User Secrets store at request time and never appears in config or the transcript. The User Secrets pane lists those names under "Needed by plugins".
url: "https://api.example.com/mcp"
headers:
Authorization: "Bearer ${secret:my_service_token}"
Requires the user_secrets plugin. A stdio server is started once and shared across sessions, so its env cannot carry per-user identity — use a remote server (or a native plugin such as Redmine) when each user needs their own credential.
For a server that expects a browser login rather than a token the user can
paste, see OAuth — the same per-user machinery underneath, with the
credential minted instead of configured.
When such a server connects
A server whose headers reference ${secret:…} **does not connect at instance
startup**. It cannot: the MCP handshake (initialize + tools/list) is itself
authenticated, and a per-user secret only exists inside a turn — user_secrets
publishes the caller's secrets at onContextReady. Connecting at startup would
send an empty Authorization header and report a perfectly good server as
broken.
Instead the connection is warmed from onContextReady of the first turn, so
the handshake overlaps context loading and prompt assembly rather than delaying
the answer. Its tools appear once it completes; a turn that starts before it
does simply is not offered them. The connection is then reused for the life of
the process, so the cost is paid once, not per session.
What you see in the log tells the three states apart:
| Log line | Meaning |
|---|---|
connection deferred to the first turn because it authenticates with per-user secret(s): … (INFO, at startup) | Normal. Nothing is wrong. |
MCP server … not connected: this user has not set the secret(s) … (INFO, once per distinct reason) | Nobody has stored that secret yet — the pane's "Needed by plugins" section is where the user creates it. No request was sent. |
MCP server … rejected the credential built from secret(s) … (ERROR) | The secret is set and the server refused it (401/403) — expired, revoked, or wrong value. |
Failed to start MCP client: … with a traceback (ERROR) | Anything else: unreachable endpoint, protocol error. |
A start that really failed is not retried before ~60s, so one bad endpoint
costs a round trip a minute rather than one per prompt.
One connection per credential: the client cache is keyed by server config
and a digest of the resolved credential, so each caller gets their own
connection, their own tool list, and their own lifecycle. That is deliberate
rather than tidy — an MCP server before revision 2026-07-28 mints a session
against the token presented at initialize, and the specification's rule that
such a server must verify every inbound request and
never authenticate by session
is not one a client can enforce. Two users who share one token legitimately
share a connection; rotating a token opens a new one and abandons the old
session.
A connection each does not have to mean a tools/list each: a server that
marks its list cacheScope: public is offering one list for every caller, and
Codumentor will serve it to the next credential rather than ask again. See
Keeping the tool list current.
At most 16 connections are kept per configured server (_MAX_CLIENTS_PER_SERVER),
least-recently-used evicted first; an evicted user reconnects on their next
turn. Note that the plugin's capabilities() tool list is therefore empty
outside a turn for such a server — there is no one list to report.
OAuth
The other way a server authenticates the calling user, and the one for a server
whose 401 invites a login rather than a token anybody can paste. Set oauth
and configure nothing else:
url: "https://mcp.example.com/mcp"
oauth: true
There is deliberately almost nothing to configure. The MCP authorization
specification has the server publish where its authorization server is
(protected-resource metadata) and has the authorization server accept a client
that registers itself, so
there is no app to create, no client id to copy and no endpoint to write down.
Codumentor discovers all of it on the first connect, registers this deployment
as a client, and runs authorization-code with PKCE. Two knobs exist for the
server that needs them:
oauth:
enabled: true
scopes: [] # empty = ask the server what it needs
client_name: "Codumentor" # the name on the consent screen
Leave scopes empty unless the server documents scopes it does not advertise:
empty means the scopes come from the server's own WWW-Authenticate challenge
and published metadata, and a scope the authorization server does not know is a
refused authorization.
oauth requires url — authorization is an HTTP exchange with the server, and
a stdio server has no HTTP layer. An Authorization header configured alongside
oauth is ignored (with a warning at startup): the access token minted for each
user replaces it.
One thing the operator must get right
api.external_url. Codumentor registers a single callback URL with each
authorization server — <external_url>/mcp/oauth/callback — and every later
authorization is checked against what was registered. With external_url unset
it falls back to http://<api.host>:<api.port>/mcp/oauth/callback, which is
correct for a loopback install and wrong for anything behind a proxy. Forwarded
headers are deliberately not consulted even when api.trust_forwarded is set,
because a redirect URI that varied per request would turn one proxy hop into
redirect_uri_mismatch. GET /ui/plugins/mcp/oauth/servers reports the URL it
will use, which is the cheapest way to catch this before a flow fails on it.
Connecting
Each user connects themselves, once per server, in **Settings → Extensions →
MCP Servers**. One section covers every OAuth server the deployment configures
(one plugin entry is one server, but the pane is one), and each is listed with:
- its state — not connected, connected, or "the server no longer accepts your authorization", which is the one that needs pressing again;
- the tools it is offering, when the connection is open. Most of the time it is not, and the pane says so: a per-user connection is opened by a turn, so "connected" between messages means the authorization is stored and will be used on the next one. Refresh re-reads it;
- Connect / Disconnect. Connect opens the authorization URL in a new tab and follows the flow to its end; disconnecting forgets the stored authorization and drops the live connection, so the tools stop working at once.
The callback URL this deployment registers is printed under the list — the
cheapest way to catch the api.external_url mistake described above.
The TUI has the same pane (Settings → Plugins → MCP Servers) with one
difference forced on it: the authorization server redirects a browser to an
HTTP route, and the TUI process serves no HTTP, so that page cannot land there.
The pane therefore prints the authorization URL, opens it if it can, and asks you
to paste back the address your browser was redirected to — the code is unspent, so
the authorization completes exactly as it would in the web UI. (Pasting just the
code=… value works too.)
Underneath both, the same five routes:
| Route | What it does |
|---|---|
GET /ui/plugins/mcp/oauth/servers | every server that authorizes per user, whether this user is connected, and the callback URL in force |
POST /ui/plugins/mcp/oauth/flows {"server_id": "…"} | starts a flow and answers with login_url — open it in a browser |
GET /ui/plugins/mcp/oauth/flows/{flow_id} | poll: awaiting_callback → complete or failed |
DELETE /ui/plugins/mcp/oauth/servers/{server_id} | disconnect: forget the authorization and drop the live connection |
GET /mcp/oauth/callback | where the authorization server redirects back to. Public by necessity — the redirect may land in a browser session that never logged in — and authenticated by the state parameter, which names a flow an authenticated request started |
All but the callback require the oauth:manage permission. A user has ten
minutes to finish in the browser; a flow that goes unanswered expires and can be
started again. A replayed redirect does nothing.
A POST /flows that answers complete with no login_url is not an error: the
server did not challenge us, so this user is already connected.
Where the authorization is kept
In that user's User Secrets store — the same encrypted
SQLite file, the same auth.jwt_secret-derived key — as one row per server,
named mcp_oauth_<host>_<digest>. Three consequences worth knowing:
- The row is written host-only, so it never reaches a turn: no
${secret:…}can expand to an access token and no shell binding can carry one into a sandbox. - It is listed in the User Secrets pane like any other secret, and deleting it there is a legitimate way to disconnect — the next turn finds no token and the server's tools go quiet.
- Without
auth.jwt_secretthe store falls back to plaintext, as it does for every other secret, and says so loudly. Set it.
The row holds the dynamic client registration as well as the tokens, so each
user of a server registers once. It also holds the authorization server's
metadata and the token's absolute expiry, which is what lets a restarted
Codumentor refresh an expired token instead of asking the user to consent again.
What you see in the log
| Log line | Meaning |
|---|---|
connection deferred to the first turn because it authenticates as the calling user (OAuth) (INFO, at startup) | Normal. |
MCP server … not connected: this user has not authorized … yet (INFO) | Nobody has connected it for this user. No request was sent. |
MCP server …: this user's authorization is missing or no longer accepted (INFO) | There was a stored authorization and it no longer works — revoked, or expired past refreshing. The user connects it again. |
MCP server … authorized by user=…; it offers N tool(s) (INFO) | A flow completed, and the connection it proves works was opened and listed. |
An access token that lapses while a connection is live is refreshed underneath
it. If that refresh is refused mid-turn, the tool call comes back telling the
model to ask the user to reconnect rather than to retry.
Access tokens are never revoked upstream on disconnect — the MCP specification
does not require an authorization server to publish a revocation endpoint, so
"disconnect" means Codumentor forgets the authorization and stops using it. A
user who wants the grant torn down does that at the provider.
Keeping the tool list current
A server's tools are not fixed for the life of the connection any more. Three
things can bring Codumentor's copy of the list up to date, and they answer
different questions:
| Signal | What it is | When it fires |
|---|---|---|
tools/list_changed | the server saying its tools moved | at once on a 2026-07-28 server, between turns; at the next prompt on an older one |
ttlMs | how long the server says its list stays fresh | at the next prompt after it runs out |
| a reconnect | a new connection lists for itself | when a server is restarted, replaced, or reached by a new credential |
tools/list_changed. On a 2026-07-28 server this arrives over a
subscriptions/listen stream that stays open alongside the connection; on an
older one it is a plain notification the connection already carries. Either way
the list is refetched and the agent's tools are rebuilt — a tool the server
added appears, one it dropped stops being offered and stops resolving, and a
changed schema reaches the model. watch_tool_changes: false opens no stream
and ignores the notification; use it for a server whose event stream
misbehaves, and expect the list to move only on ttlMs and reconnects. A
stream that will not stay open is abandoned after a few attempts, with a
warning naming the server.
ttlMs. Honored when it is positive. ttlMs: 0 is read as *"this server
makes no promise about freshness"* and leaves the list alone — not as "ask
again on every prompt", which is what a literal reading would cost, and 0 is
what a server that simply did not think about caching sends. A server with
something to say about its tools says it through the signal above.
A reconnect asks the server unless the server's own ttlMs still covers the
list, in which case the cached one is reused — except when the connection is
replacing one that died, where the server may have come back as a different
one and is always asked.
cacheScope. private — the default, and the safe reading — means the
list belongs to the caller who fetched it, and Codumentor never shows it to
another credential. public is the server asserting that its list does not
vary by caller, and Codumentor takes it at its word: the second user of a
per-user server is offered the tools without a tools/list of their own — for a
paginating server, without its first page; the rest of the walk is still
theirs. That is the server's claim, in the same way readOnlyHint is; a server
that varies its list per user and marks it public is mislabelling its own
response. The tools still execute against each caller's own connection and
credential either way.
A list that arrives in pages. tools/list is paginated, and Codumentor
follows the server's cursor to the end of the list rather than offering its
first page. Two things worth knowing about a server that does paginate. If its
list changes while Codumentor is walking it, the server says so by refusing the
cursor, and the walk starts again from the beginning — once; a second refusal is
reported with the server's own error and the list is left as it was. And a
server still handing out cursors after 50 pages has its walk stopped: the tools
collected so far are what the agent is offered, with a warning naming the
server, so a truncated list is never silent. A tool that turns up on two pages
of one walk is offered once.
What the agent sees
Tools keep the names the MCP server advertises. In logs they appear with an [MCP] prefix, e.g. [MCP] read_file(path='/path/to/file').
One process (or remote connection) is started per unique server config — per
credential, where the config carries one — and reused. Opening a connection is
given 30 seconds and every request 30 seconds, so a silent server fails the tool
call rather than the turn. If the server fails to start, the plugin logs the
error and retries on a later request — no sooner than ~60s later, so the failure
is reported once rather than on every prompt.
A stdio server's stderr is drained continuously into the Codumentor log at DEBUG
level. That is not only for the diagnostics: a full stderr pipe blocks the
server, and a blocked server stops answering.
Notes
- Only run stdio executables you trust — the sandbox limits the damage a misbehaving server can do, it does not make an untrusted one safe.
- Prefer
${secret:<name>}over plaintext tokens inheaders/env. - Troubleshooting: missing executable or permissions; MCP server not listing tools; errors on the server's stderr (Codumentor logs them). Check Codumentor logs for MCP startup failures.