Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

MCP Servers

MCP – Model Context Protocol – is the wire format outrig uses to talk to tools. Each MCP server is a child process that runs inside a session container and speaks JSON-RPC over its stdio. outrig connects to each one by podman exec -i’ing into the container, hands the resulting stdio pair to the rmcp client, and treats every tool the server advertises as a Rig dynamic tool.

By default a server runs in the session’s primary workspace container. An entry can instead opt into a sidecar – an extra container owned by the session – so the server’s runtime dependencies stay out of the workspace image and the server sees only what its container is granted. See Sidecar placement below and Containers for the container-side details.

The same [images.<name>.mcp] table is consumed by both outrig run and outrig mcp. outrig run registers those tools with its built-in agent; outrig mcp republishes them as one stdio MCP server for an external client. See Usage -> outrig mcp for client setup and transport rules. When outrig mcp --attach points at an existing container, it still starts its own MCP child processes inside that container; it does not share or proxy the host session’s existing MCP protocol state.

Declaring servers

MCP servers are configured per-image, as a map keyed by the server’s local name:

[images.coding.mcp]
fs    = { command = ["mcp-server-filesystem", "/workspace"] }
shell = ["bash", "-lc", "exec shell-mcp-command"]
build = { command = ["cargo-mcp"], env = { CARGO_HOME = "/workspace/.cargo" } }

shell-mcp-command is a placeholder for the shell MCP package you choose and install in the image. OutRig supports arbitrary MCP commands; outrig image add only renders package recipes for the MCP servers it can install without more input.

Each entry is one of:

  • A bare array (short form): ["bin", "arg1", ...]. Equivalent to { command = ["bin", "arg1", ...] } with no extra env.
  • A table (full form): { command = [...], env = { KEY = "value", ... } }. The env map is added to the podman exec invocation for this server. A ${VAR} value is passed by name, off podman’s command line, and outrig shows it only as the reference; see the value syntax. In a repo config a ${VAR} may name only a variable the global config lists in [repo] env-allow.

Server names must match ^[a-zA-Z][a-zA-Z0-9_-]*$ and must be unique within an image-config. Names are how you reference servers elsewhere – in outrig logs <session> <server>, in tool-call traces, in the prefix that gets attached to the tools the server exposes (see Tool name prefixing for the length that prefix holds to).

Sidecar placement

A full-form entry can name the container it runs in:

[sidecars.tools]
image     = "mcp-tools"        # sibling [images.mcp-tools] block first, else raw podman ref
workspace = "ro"               # "none" (default) | "ro" | "rw"

[sidecars.serve]
image     = "docker.io/mcp/filesystem:latest"
args      = ["/workspace"]     # argv for the image's ENTRYPOINT
workspace = "ro"

[images.dev.mcp]
# Runs in the primary container, exactly as before.
local = ["mcp-local", "--stdio"]
# exec-stdio in the named sidecar "tools".
fs    = { command = ["mcp-fs", "/workspace"], sidecar = "tools" }
# exec-stdio in a dedicated anonymous sidecar built just for this server.
grep  = { command = ["mcp-grep"], image = "ghcr.io/example/mcp-grep:1" }
# entrypoint-stdio: no command; the image ENTRYPOINT is the server.
fetch = { image = "ghcr.io/example/mcp-fetch:2", env = { TOKEN = "${FETCH_TOKEN}" } }
# entrypoint-stdio in the named sidecar "serve", which supplies the argv.
ws    = { sidecar = "serve" }

The placement shapes:

  • Primary (default). No placement key. The short form (bare array) always runs in the primary.
  • Named sidecar: sidecar = "<sc>" names a top-level [sidecars.<sc>] block; with a command, the server is podman exec’d in that container. Naming it is also what starts it – blocks are shared, so a session only runs the ones its [mcp] entries reference.
  • Anonymous sidecar: image = "<ref>" plus command gives this one server a dedicated container with all defaults (no workspace, no mounts, on-failure = "abort"). Anything fancier – workspace access, mounts, security – requires promoting to a named block. sidecar and image are mutually exclusive.
  • Entrypoint sidecar: an entry without a command runs its container’s ENTRYPOINT as the server over piped stdio – the off-the-shelf MCP image pattern, zero repo-side command knowledge. Container lifetime equals server lifetime: the server exiting removes the container, surfacing exactly like a mid-session sidecar death (tools error, session survives). Two ways to write it:
    • image = "<ref>" with no command – the one-liner, all defaults.
    • sidecar = "<sc>" with no command – the named block becomes the entrypoint host, which is how such a server gets a workspace view, mounts, or its own security policy.

Every one of these is reachable from the library API as well as from a config file. A SidecarSpec carries the same workspace, view, mounts, and security a [sidecars.<sc>] block does, and hosts either exec-stdio servers (with_server) or a single entrypoint-stdio one (with_entrypoint_server, whose args become the container argv). LaunchSpec::from_config lowers a parsed config into those same specs, so an embedding program and outrig run reach the same placements and produce the same containers.

An entrypoint server’s arguments come from args, on the entry or on its sidecar block – never both. Without it, docker.io/mcp/filesystem and most real MCP images cannot be reached at all, since they take their served directories positionally:

[images.dev.mcp]
serve = { image = "docker.io/mcp/filesystem:latest", args = ["/workspace"] }

args is for images whose server is an ENTRYPOINT. podman appends trailing arguments to an exec-form ENTRYPOINT, so ["node", "/app/dist/index.js"] plus args = ["/workspace"] runs node /app/dist/index.js /workspace. But it replaces CMD – an image that puts its server in CMD instead loses it, and nothing starts.

Because the container process is the server, an entrypoint host serves exactly one server and cannot be start = "manual". It also skips the in-container user bootstrap: that needs a running container, and an entrypoint host runs its server as the first process, so there is no window for it between the container’s creation and the attach that runs the entrypoint. The image’s own USER therefore applies to any workspace or mounts the block declares. view = "primary" is the exception – see below: its payload runs as the session user whatever the image says, because OutRig’s own launcher is the process that starts it.

Entrypoint sidecars never race session network policy: the container is created and initialized with its entrypoint held un-executed, audit/filter interception attaches to its network namespace, and only then does the entrypoint run. Its first packet is already subject to policy.

Primary filesystem view

An entrypoint sidecar can go one step further and run against the primary container’s filesystem view with view = "primary" – the primary’s rootfs and every mount, at the primary’s paths, with the sidecar image supplying its own runtime:

[images.dev.mcp]
# The quickstart one-liner: an off-the-shelf filesystem server over the primary's view.
fs = { image = "docker.io/mcp/filesystem:latest", view = "primary", args = ["/workspace"] }

The sidecar’s outrig-enter entrypoint joins the primary’s mount namespace and grafts the sidecar’s own rootfs aside at /mnt, then execs the image’s server. This is what lets an unmodified docker.io/mcp/filesystem (Alpine) index a Debian project’s tree – neither image knows about the other. view = "primary" is entrypoint-stdio only, and mutually exclusive with workspace (the view already contains it).

A ref written inline like that is resolved the way --image is: an [images.<name>] block first, else a raw podman ref that must already be present locally – OutRig will not pull it, so that a typo cannot reach a registry. To have OutRig pull the image, name it in a block of its own and point the entry at that:

[images.mcp-filesystem]
image-name = "docker.io/mcp/filesystem:latest"

  [images.dev.mcp]
  fs = { image = "mcp-filesystem", view = "primary", args = ["/workspace"] }

The entrypoint program must be an ELF binary. outrig-enter opens it before joining the primary’s namespace and classifies it; a #! script is refused, because its interpreter line would resolve against the primary’s rootfs and quietly run something other than the image’s own. Published MCP images often ship a console script – docker.io/mcp/git’s ENTRYPOINT is one – and the fix is a two-line image of your own that names the interpreter and passes the script to it:

FROM docker.io/library/python:3.13-slim
RUN pip install --no-cache-dir mcp-server-git==2026.7.10 mcp==1.29.0
ENTRYPOINT ["/usr/local/bin/python3", "/usr/local/bin/mcp-server-git"]

The argument asymmetry. Because OutRig rebuilds the server’s command line, two kinds of path in it mean different things:

  • Elements from the sidecar image – its ENTRYPOINT and CMD – name files in the sidecar’s own rootfs, now under the graft. OutRig prefixes absolute ones with /mnt. The program itself is the exception: outrig-enter opens it before joining the primary’s namespace, while the sidecar’s own rootfs is still at /, so it stays bare and the helper applies the graft itself when handing the path to the loader.
  • Elements you wrote in args name paths in the primary’s view. They are passed bare.

So args = ["/workspace"] against an image whose entrypoint is /usr/local/bin/node /app/dist/index.js runs /usr/local/bin/node /mnt/app/dist/index.js /workspace – the tool from the sidecar, the directory from the primary.

HOME is the primary user’s. The payload runs as the session user, but the sidecar image sets HOME for whoever it expects to run as – usually root, whose /root is 0700. So the container gets --env HOME=<primary's home>, the same directory every exec-stdio server is given, and an env entry of your own for HOME still wins. Without it the breakage is oblique rather than obvious: git cannot read core.excludesFile under an unreadable $HOME and fails rather than skipping it, so cargo reports a fingerprint error – and only a tool that writes to $HOME would have looked guilty.

/proc is the payload’s own. The launcher joins the primary’s mount namespace and nothing else, so the primary’s /proc – an instance of the primary’s PID namespace – has no entry for the payload, and /proc/self there resolves to nothing. The launcher mounts a fresh proc over it in the private namespace it already holds, so /proc/self means the payload, which is what a program asking about itself intends. Programs that read it are more common than they look: rustup’s cargo shim resolves its own toolchain that way. The trade is that the primary’s process list is not visible through the view – it is a filesystem view.

/etc/ld.so.preload is empty for the payload. glibc’s loader reads that file on every start, by that absolute name, and no loader option turns it off. After the join it is the primary’s file, so a primary that ships one – for an allocator, say, or a shim – would have its library loaded into the sidecar’s server before main, with the server’s environment and network, and one built for a newer glibc than the sidecar’s would stop the server from starting, with a loader error naming a file the sidecar never had. So when the payload’s loader is not musl’s, the launcher mounts /dev/null over the file in the private namespace it already holds, and the loader finds it empty. The primary keeps its own file. The mount is in the payload’s namespace, though, so everything the server starts sees the file empty too, the primary’s own binaries included: they do not preload what the primary configured. That holds only while the primary leaves the file alone. The mount covers the file as it is when the server starts, and the kernel drops a mount whose file is renamed over or deleted, so when the primary replaces the file later – with sed -i, or by deleting it and writing another – the server’s later children read the replacement, and a glibc one preloads what it lists. A write in place keeps the mask. A file the primary creates after the server started is never masked. The server itself read the file once, at its start, and is not affected. A musl loader has no such file and a static payload has no loader, so none of this applies to either.

The engine must be local. The launcher binds the primary’s /proc/<pid>/ns from this machine, so the podman engine has to share this machine’s kernel and PID namespace. A socket on this host works; a remote engine (another host, a VM, another container) is refused up front.

A dynamic payload cannot re-execute itself. The kernel looks for a binary’s loader (its PT_INTERP) in the process’s own root, which after the join is the primary’s rootfs – where the sidecar’s loader is absent or is a different one. So the launcher does not exec a dynamically linked payload directly; it runs the sidecar’s loader explicitly, as /mnt/lib/ld-musl-x86_64.so.1 --library-path /mnt/lib:... /mnt/usr/local/bin/node .... The payload then gets wrong answers to the questions a self-re-exec asks: /proc/self/exe names the loader, so executing it runs a loader with no program, and argv[0] is the server’s path under the graft – a file that exists but that the kernel cannot run by name, because it goes looking for the loader in the primary. Failures are oblique. Python’s subprocess.run([sys.executable, ...]), and so multiprocessing’s spawn and forkserver, get ENOENT for a file that is plainly there; Node’s child_process.fork() runs process.execPath, which is the loader, and the loader tries to load the script as a program; a Go os.Executable() or Rust current_exe() re-exec fails the same way.

A static payload has no loader and is exec’d from an open file, so executing /proc/self/exe itself – the idiom of container runtimes – does re-run it. What fails are the paths: none of the sidecar’s rootfs is grafted for it, so argv[0] names a file the view does not have (ENOENT), and what /proc/self/exe reads back as – which is what os.Executable() and current_exe() hand over – is /, which cannot be executed.

A server that does its work in one process – the quickstart’s filesystem server – is unaffected. One that starts copies of itself cannot take view = "primary" unless it is a static binary that executes the literal path /proc/self/exe; run it as an entrypoint sidecar in its own image, with the project mounted through workspace instead.

A program named without a / – ENTRYPOINT ["node", "/app/dist/index.js"], which is what the quickstart image above declares – is searched along the sidecar image’s PATH, in that same pre-join instant and for the same reason: the program is one of the sidecar’s own files, so the sidecar’s PATH is the one that can find it. Only absolute PATH entries are searched, since a relative hit could not be named under the graft afterwards. When nothing matches, startup fails with a message naming the program and the PATH that was walked.

view = "primary" is a real posture change: the container starts with CAP_SYS_ADMIN and CAP_SYS_PTRACE in the primary’s user namespace, and the server can read the primary’s whole filesystem. See MCP Trust Model and SECURITY.md. It needs the outrig-enter helper. The helper is supported on Linux x86-64 and AArch64 and is compiled when the matching <arch>-unknown-linux-musl Rust target is installed; without it a view = "primary" session fails at start with a message naming the missing artifact and the specific build-time reason. The helper is always a Linux binary because it runs inside the container, not in the host process. macOS and native Windows are unsupported; WSL2 is Linux and uses the normal path.

The capabilities belong to the launcher, not to the server. outrig-enter needs them for the namespace join and the graft, and gives them up the moment that work is done: it becomes the session’s uid/gid immediately before exec’ing the payload, which clears its capability sets with the uid transition. The server – and anything it shells out to – therefore runs as the same user every other OutRig-launched server runs as, so what it writes into the workspace is yours rather than a subuid you cannot chown back, and a root-owned path in the primary is out of reach. The image’s USER does not enter into it.

That posture is identical from the library. SidecarSpec::with_view(SidecarView::Primary) takes the same capabilities in the same namespace, drops to the same ids, and a build without the helper fails the same way, before any container is created. The helper is materialized into the session’s log directory, which is the one writable location a LaunchSpec names.

Exec-stdio named sidecars honor their image’s org.outrig.mcp label with the usual semantics, scoped to that sidecar: label-declared servers materialize as exec-stdio servers in that sidecar, and repo config overrides by server name (the whole entry, placement included). The label adds servers to a sidecar an [mcp] entry already started; it cannot start one itself. Labels are inert on anonymous sidecars and on entrypoint hosts – exactly the one declaring server runs there. Labels may not carry args for the same reason they may not carry placement keys: they describe exec-stdio servers in the image that carries them, whose arguments belong in command.

The server-name namespace stays flat per session, across the primary and every sidecar. If two sidecar images both advertise the same name and neither is overridden, session start fails with an error naming both hosts; add an override by name in [images.<name>.mcp] to pick one.

A sidecar that fails to start, bootstrap, or connect any of its servers is handled per its on-failure key: abort (the default) fails the session start; warn logs to stderr, skips the sidecar and every server it hosts, and continues with a reduced tool set. Primary-container and primary-hosted-server failures always fail fast.

Embedding MCP config in the image

An image can also carry its MCP declarations in its org.outrig.mcp OCI label. You author them as a [mcp] table in the project’s image.toml, which outrig image build serializes into the label:

# image.toml
[mcp]
fs    = { command = ["mcp-server-filesystem", "/workspace"] }
shell = ["bash", "-lc", "exec shell-mcp-command"]
build = { command = ["cargo-mcp"], env = { CARGO_HOME = "/workspace/.cargo" } }

The [mcp] table uses the same short and full entry shapes as [images.<name>.mcp]. At session startup, outrig reads the org.outrig.mcp label off the image, then overlays entries from config.toml. If both sources define the same server name, the config.toml entry replaces the image entry in full; fields are not deep-merged. Servers that appear in only one source remain in the merged set.

“The image” there is the one the primary container is running, addressed by the engine’s id for it, not whatever the tag it was started from names at the moment of the read. A tag is a pointer and a pull of a re-pushed ref moves it, while every container already started from the old image goes on running that one. So the merged servers are always ones the container can serve, even when outrig mcp --attach borrows a container started before the last pull. A repo-local build image is named by a content hash, which no pull can re-point.

A label entry may carry call-timeout-secs, since the image’s author knows how long its tools run (see What if a server hangs?). The placement keys and args are rejected in a label.

A ${VAR} in a label entry’s env is read from your environment and handed to the server. The label is text the image’s author wrote – a Dockerfile in the repo, or an image the repo names – so it is held to the same [repo] env-allow list as a repo config’s references. A reference to a variable the list omits is refused when the session starts, naming the server, the key, the variable, and the image; the variable is never read. A server your config declares by name replaces the label’s entry and is not held by this. An image of your own that references a variable needs it listed to start.

For build-from-Dockerfile repo images, outrig build also stamps the cache image with the same merged org.outrig.mcp label it would use at startup. The startup overlay still runs, but is idempotent for those repo-local entries. This means outrig image inspect <name>:<hash> can show the declared servers without starting a container.

Use embedded MCP config when a shared image owns the tool binaries and their default commands. Use config.toml for repo-local additions or overrides. A repo that wants to delegate completely to the image can omit [images.<name>.mcp].

The org.outrig.mcp label is not required; this allows a shared image to be used with different configurations via config.toml. Malformed JSON, invalid server names, and empty command arrays are errors because they mean the image metadata is broken: a startup error for an image outrig did not build, and a build error for a repo-built image, whose build reads the label it inherited before it merges [images.<name>.mcp] into it.

That build error names the image-config (image "coding" embedded config: ...) and ends with the way past it. The label comes from a LABEL in the image’s Dockerfile or from its base image, so replace it in the Dockerfile, after the FROM:

FROM registry.example.com/shared/tools:3
LABEL org.outrig.mcp='{}'

An empty table declares no servers, so the build keeps only what [images.<name>.mcp] adds. Set a corrected value instead to keep the servers the base image meant to declare.

To inspect what will actually start, run:

outrig mcp show-merged --image coding

The command starts the selected container, reads the org.outrig.mcp label off the primary image and every named sidecar image, applies config.toml overrides, prints the effective [mcp] table to stdout (without launching sidecar containers), and then stops the container. Each server carries a comment naming its placement and where it was declared:

[mcp]
# fs: primary (image label org.outrig.mcp)
fs = ["mcp-server-filesystem", "/workspace"]
# search: sidecar "tools" (config.toml)
search = { command = ["mcp-search"], sidecar = "tools" }

Lifecycle

When outrig run starts, the primary container comes up first, then every start = "auto" sidecar, then network interception attaches to each container, and only then do MCP servers connect – primary-hosted and sidecar-hosted alike, in name order. The sequence per MCP server is:

  1. podman exec -i <container> <command> – outrig spawns the server as a child process inside its placement’s running container, with stdin/stdout piped back to outrig.
  2. Initialize handshake – outrig sends the MCP initialize request and reads the server’s capabilities.
  3. Discover tools – outrig calls tools/list and receives the list of advertised tools, each with a name, description, and JSON Schema for its inputs.
  4. Register with Rig – each discovered tool becomes a McpToolAdapter that implements Rig’s dynamic-tool trait. The agent now has access to it.

All servers come up before the REPL accepts any input. If any server fails to initialize – including one that stops answering – outrig run reports the error on stderr, with the server’s stderr so far, and exits before the REPL starts. You don’t get partial sandboxes.

When the REPL terminates (Ctrl-D, Ctrl-C, or LLM error), outrig closes each server’s stdin in turn, waits up to 5 seconds for the process to exit, then stops the containers – sidecars first, primary last. Servers don’t see SIGTERM directly; they see EOF on stdin, which the MCP spec defines as the normal shutdown signal.

In attach mode, outrig mcp --attach borrows a container that something else owns. It shuts down only the MCP children it started and leaves the borrowed container running. If the owner stops the container while the attacher is live, the attacher exits instead of trying to relaunch it.

Dynamic addition

Sidecars can also join a session after it starts. The primitive is the library API: Outrig::add_sidecar(SidecarSpec) accepts an arbitrary spec – a raw podman image ref, a workspace view, a filesystem view, mounts, a security block, and either exec-stdio servers or an entrypoint-stdio one – starts the container with session labels and keep-id, attaches the session’s network interceptor, connects the servers, and extends Outrig::tools. LaunchSpec::with_sidecar(SidecarSpec) declares the same thing at launch time.

The two transports differ in what “starts the container” means. An exec-stdio sidecar is started and then exec’d into, so it outlives any one server. An entrypoint-stdio sidecar is created with its server’s environment baked in and only runs when the server does, so adding one mid-session gives the session a container whose lifetime is that server’s – the same coupling a config-declared entrypoint host has.

The REPL surface is narrower by design: /sidecar add <name> starts a sidecar the image config declared with start = "manual", and nothing else – unknown names and already-running sidecars are errors, and there is no /sidecar stop. New tools become available to the agent on the next turn. /sidecar list shows every declared sidecar with its status (running, not started, exited) and the servers it hosts.

Dynamic-add failures are reported only to the caller – the REPL prints the error, the library returns Err – and the session stays healthy: anything the failed add started (container, interceptor attachment, connected servers) is torn down, and the existing tool set is untouched. This is deliberately different from the session-start on-failure policy, which decides whether a launch-time sidecar failure aborts the session.

There is intentionally no agent-invocable tool for adding sidecars: the agent must not grow its own environment. Only the human at the REPL or the embedding program can.

Tool name prefixing

Two different MCP servers can have a tool with the same name (e.g. both fs and archive might define read_file). To avoid collisions and keep the LLM’s tool-name space predictable, outrig prefixes every tool with its server name, as far as the 64-character limit on a name allows (see Long server names):

fs__list_directory
fs__read_file
fs__write_file
shell__exec
build__cargo_check

The separator is __ (double underscore). A combined name that already fits OpenAI’s ^[a-zA-Z0-9_-]{1,64}$ constraint and splits back into its pair – its first __ is the separator – is advertised exactly as written, which is the ordinary case. Any other name is lossy, in one of three ways: a character outside the set had to become _, the name was longer than 64 characters, or the separator was ambiguous because the server name itself ended in _ or contained __. A lossy name is truncated to fit and tagged with a stable hash suffix over the (server, tool) pair, so two tools that would otherwise collapse onto one name stay distinct:

ServerToolAdvertisedWhy
fsread_filefs__read_filealready well-formed
fsread/filefs__read_file_5d2270/ had to be replaced
fsread filefs__read_file_4e31b7so did the space

The suffix is derived from the server and tool names alone, so an uncontested name is the same in every session and a client may cache it across reconnects. Sixty-four characters cannot encode an unbounded pair of names, though, so two tools can still land on one name – two suffixes agreeing, or a tool whose own name already ends the way a suffix does. outrig detects that, widens one side’s suffix until the two names differ, and logs both identities.

Which side moves is decided by the two tools’ identities, not by the order a server listed them in, so a restarted server that relists its tools differently cannot hand a name a client cached to a different tool. A widened name does depend on the rest of the session’s tool set, so that one is not cacheable across a change to the servers in play.

The LLM sees fs__write_file in its tool list and emits tool calls under that name. outrig’s router looks that name up in a table it built at startup and dispatches to the matching MCP client under the original tool name. It does not recover the pair by splitting the string, so a suffixed name routes like any other.

You can list every tool currently registered with the agent from inside the REPL:

> /tools
[outrig] tools available (4):
  fs__list_directory   List the contents of a directory.
  fs__read_file        Read the contents of a file.
  fs__write_file       Write contents to a file (overwrites).
  shell__exec          Run a shell command and return its stdout/stderr.
>

Long server names

The server-name rule bounds spelling, not length, and a rewritten name is cut at a fixed length without regard for where the server name ends, so the prefix survives a rewrite only while the server name leaves room for it. At the default six-character suffix, a rewritten name for a server whose name is:

  • 54 characters or fewer reads <server>__<start of the tool>_<hash>.
  • 55 characters keeps <server>__ and none of the tool’s name.
  • 56 characters keeps half the separator, so it reads <server>__<hash>.
  • 57 characters or more is cut inside the server name: the first 57 characters of it, _, and the hash, with no __.

A widened suffix leaves less room, so a widened name loses the prefix at a shorter server name. A name advertised as written is not affected, and begins <server>__ in full. Routing is not affected either, since a name is looked up whole rather than split. What suffers is legibility: a person or a model reading the tool list cannot tell which server hosts such a tool, which is why outrig mcp tells its client to treat names as opaque and call each exactly as listed. Keeping server names to 54 characters or fewer avoids it for every name outrig does not have to widen.

Protocol revisions and list caching

Both servers outrig exposes – the session proxy (outrig mcp) and the self-description server (outrig mcp self) – advertise an explicit list of MCP protocol revisions rather than deferring to whatever the underlying SDK happens to know:

RevisionRequested in
2024-11-05initialize
2025-03-26initialize
2025-06-18initialize
2025-11-25initialize
2026-07-28each request’s _meta

The list is pinned deliberately. An SDK upgrade that teaches the library a newer revision would otherwise widen what outrig agrees to speak without anyone checking that outrig actually meets the new revision’s requirements – and a revision can add mandatory response fields, which turns a silent widening into a client that cannot load any tools at all.

2026-07-28 replaced the initialize handshake with metadata on every request: a client speaking it names the revision in each request’s _meta and is answered in it. An initialize asking for anything the handshake cannot grant – a revision outside this list, or 2026-07-28 and later – is answered in 2025-11-25, the newest revision that has a handshake. Note that this is a fallback rather than a step down: a client asking for something older than anything listed is answered in a newer revision, not an older one. A revision named per request has no fallback: one outside the list is refused with an error that lists the revisions served.

From 2026-07-28 onward, list results must carry cache metadata (SEP-2549): how long the result may be treated as fresh, and who may cache it. Both servers tag tools/list accordingly:

outrig mcp self
cacheScope: public. The tool set is compiled into the binary, so it is identical for every user of a given outrig version and any intermediary may cache it.
outrig mcp
cacheScope: private. The tool list is the union of this session’s backing servers, which depends on the active config, image-config, and any --env overrides – not shareable.

Both declare a five-minute freshness window. Neither tool list can actually change mid-connection (the table is built once at startup, and no listChanged capability is advertised), so a longer window would still be truthful; five minutes is chosen so that a config edit or a rebuilt image is picked up promptly on the next session.

What if a server crashes mid-session?

A crashed MCP server is surfaced as a tool-call error to the LLM, which usually causes the model to stop calling that tool and tell you about the failure. outrig does not auto-restart MCP servers in v0 – the server is gone for the rest of the session.

A sidecar container that dies mid-session behaves the same way, uniformly and regardless of its on-failure setting: outrig logs the death to stderr, every tool the sidecar hosted returns errors, and nothing restarts. If the primary container dies out from under outrig (a manual podman kill, the OOM killer), outrig reaps every sidecar and ends the session with an error.

TODO: Incomplete – auto-restart and per-server health-checking are deferred.

The server’s stderr, captured to <session_dir>/logs/<server>.stderr, usually has the actual error. See Sessions for how to view it.

Attach mode can run more than one copy of the same MCP server in one container. Prefer servers that are reentrant-safe: no fixed listening port, global pidfile, or exclusive lock unless the server is explicitly designed to coordinate multiple copies. If a server cannot run twice, the second copy should fail clearly during startup and its stderr log will show the underlying conflict.

What if a server hangs?

Every request outrig sends a server has a deadline, so a server that stops answering costs a bounded wait rather than the session:

  • initialize and tools/list get 120 seconds each. A server that misses either fails startup the way a crashed one does, with the stderr it has written so far. A tools/list whose pages would never end – one that hands back a nextCursor it already sent, or is still paging after 1,000 pages – fails startup the same way, without waiting out its 120 seconds.
  • tools/call gets the server’s own call-timeout-secs, else the top-level mcp-call-timeout-secs, else 10 minutes. A call that runs past it is cancelled at the server with notifications/cancelled, and the model gets a timeout error in place of a result. The session goes on, and the server stays connected.

A call outrig stops waiting for early is cancelled the same way: one in a turn interrupted with Ctrl-C, or one whose outrig mcp client sent its own cancel. Cancellation is advisory – the MCP spec lets a server finish the work anyway – so a tool with side effects may still complete them.

Raise call-timeout-secs on a server whose tools legitimately run long, such as a build or a test suite, rather than the session-wide default; see Reference -> Config.

Picking which servers to include

Two practical guidelines:

  • Match servers to the image-config’s purpose. A planning image probably doesn’t need shell. A coding image almost certainly needs both fs and shell.
  • Fewer servers is better when it’s enough. Every server is another initialize cost at startup, another tool list cluttering the LLM’s prompt, another process to monitor. If a single MCP server covers your needs, use one.

See also

  • Containers – the Dockerfile that has to install the server binaries.
  • MCP Trust Model – the container boundary that makes broad MCP tools practical.
  • AI-assisted design – use outrig mcp self to design custom MCP-enabled image-configs.
  • Sessions – outrig logs <session> <server> for stderr.
  • Reference -> Config – full schema for the [images.<name>.mcp] block.