Codumentor logo Codumentor

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:

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

Remote only

Both

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 saysTreated as
readOnlyHint: truea read — offered wherever target_agents admits
readOnlyHint: falsea write — offered only where write_target_agents admits
no annotation at alla 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

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 lineMeaning
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:

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:

RouteWhat it does
GET /ui/plugins/mcp/oauth/serversevery 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_callbackcomplete or failed
DELETE /ui/plugins/mcp/oauth/servers/{server_id}disconnect: forget the authorization and drop the live connection
GET /mcp/oauth/callbackwhere 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 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 lineMeaning
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:

SignalWhat it isWhen it fires
tools/list_changedthe server saying its tools movedat once on a 2026-07-28 server, between turns; at the next prompt on an older one
ttlMshow long the server says its list stays freshat the next prompt after it runs out
a reconnecta new connection lists for itselfwhen 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

See also