-
Notifications
You must be signed in to change notification settings - Fork 3
Domains Gateway
The MCP (Model Context Protocol) gateway exposes local stdio MCP servers to the model as
gateway capabilities. It comprises four cooperating modules inside src/domains/gateway/mcp/
and one orchestrating tool surface in src/tools/gateway/. The public surface is exported
through src/domains/gateway/mcp/index.ts, which re-exports the protocol framer, the
one-process client, strict configuration loading, trust resolution, and the metadata cache.
The gateway provides three operations to the model through the gateway tool
(src/tools/gateway/index.ts): find lists declared capabilities from recorded catalogs
without launching any server, describe returns one capability's metadata, and call
launches the owning server when trusted and executes the tool under the same admission as a
direct call. The MCP subsystem is the gateway's mechanism for reaching secondary capabilities
that run in external processes.
The trust model separates user-scope declarations (in the operator's
<config>/mcp.yaml, trusted by authorship) from project-scope declarations (in
.clio-coder/mcp.yaml, which require an explicit trust record before anything in them is
launched). A repository clone must not launch anything; the trust record binds the project
root, the server id, and the declaration digest.
| File | Role | Key symbols |
|---|---|---|
src/domains/gateway/mcp/protocol.ts |
JSON-RPC 2.0 framing |
createLineFramer, classifyJsonRpcMessage, encodeJsonRpcMessage, McpError, MCP_PROTOCOL_VERSION
|
src/domains/gateway/mcp/client.ts |
One-process stdio client |
createMcpStdioClient, McpClient, McpServerSpec, McpClientOptions
|
src/domains/gateway/mcp/config.ts |
Strict config loading |
loadMcpServerConfig, parseMcpConfigText, mcpServerDigest, MCP_CONFIG_CAPS
|
src/domains/gateway/mcp/trust.ts |
Trust records |
resolveMcpServers, trustMcpServer, untrustMcpServer, readMcpTrustState, canonicalProjectRoot
|
src/domains/gateway/mcp/metadata-cache.ts |
Tool catalog cache |
readMcpServerCatalog, writeMcpServerCatalog, McpCatalogIdentity
|
src/tools/gateway/mcp-capabilities.ts |
Gateway capability source |
createMcpCapabilitySource, McpCapabilitySource
|
src/tools/gateway/index.ts |
Gateway tool |
createGatewayTool, GATEWAY_OPS
|
The transport carries one JSON message per line: UTF-8 text, newline-terminated, with no
embedded newline. The framer (createLineFramer in protocol.ts) accepts byte chunks and
yields typed messages or one typed protocol error. Invalid UTF-8 is a protocol error rather
than a U+FFFD substitution, because a tool result that reached the model with silently
replaced bytes would misreport what the server said.
The framer enforces DEFAULT_MAX_LINE_BYTES (4 MiB) on inbound lines. A line longer than the
cap is refused as soon as its bytes exceed it, before any newline arrives, so an unterminated
flood cannot grow the buffer without bound. Blank lines are skipped; a trailing carriage
return is tolerated.
Outbound messages are encoded by encodeJsonRpcMessage, which serializes the message as a
newline-terminated line. JSON.stringify escapes every newline inside strings, so the line
delimiter is never ambiguous.
The protocol version is MCP_PROTOCOL_VERSION = "2025-06-18". The McpError class carries a
code from the union "timeout" | "closed" | "protocol" | "server" | "spawn" | "aborted" | "overload" and an optional data field. The "overload" code names an outbound line the
client refused to queue: on its own because it exceeds the cap, or fatally because the server
stopped reading its stdin.
createMcpStdioClient in client.ts creates a client that owns exactly one child process.
The client never restarts a dead server; a server that dies stays dead until the gateway
decides to create a new client. The client lifecycle is:
-
idle → starting:
initialize()callsspawnChild(), sends theinitializerequest with the protocol version, capabilities, and client info, and waits for the response withDEFAULT_INITIALIZE_TIMEOUT_MS(15 s). -
starting → ready: the
initializeresponse is parsed byparseServerInfo, thenotifications/initializednotification is sent, and the client transitions to ready. -
ready: the client serves
listTools()andcallTool()requests. -
closed / failed:
close()resolves with the teardown outcome;fail()records the first fatal error and rejects all pending requests.
The client enforces bounds in both directions:
-
Inbound: the framer caps lines at
DEFAULT_MAX_LINE_BYTES(4 MiB). -
Outbound:
DEFAULT_MAX_OUTBOUND_BYTES(4 MiB) bounds the bytes queued for a server that stopped reading its stdin. Crossing it is a fataloverloadthat closes the client.MIN_OUTBOUND_BYTES(4 KiB) is the smallest cap a caller may configure.
Tool listing is paginated with MCP_TOOL_LIST_CAP (500 tools) and MCP_TOOL_LIST_PAGE_CAP
(100 pages). A server that offers more tools than the cap admits is reported as truncated.
The client enforces workspace containment: containedCwd() resolves the server's cwd
lexically and after every symbolic link, and refuses a cwd that escapes the workspace root
by path or by symlink. The check runs once at creation for early feedback and again at
spawn, which is the boundary that matters.
Environment variables are built by buildSafeToolEnv from src/core/safe-exec.ts, which
merges the declared server env over the process env while filtering out unsafe entries.
Teardown covers the server's whole process group, not only the child it spawned. The client
tracks the group through a GroupPhase state machine: unspawned → live → cleaning →
released. The leader's pid doubles as the group id because the child is spawned detached.
Cleanup starts the moment the leader is seen to exit, not when close() happens to be
called. At that instant the group is still ours or is already empty, so an arbitrarily late
close() never gets to signal a number that has had time to change hands. The teardown
sequence is:
- SIGTERM to the group.
- Wait
killGraceMs(default 3 s) for the group to disappear. - SIGKILL to whatever remains.
- Wait
teardownBoundMs(default 2 s) for the group to disappear. - Release the group id.
If the group survives SIGKILL for the confirm window, the outcome reports
{ complete: false, reason: "group-survived-sigkill", pgid, boundMs } and the pipe ends are
destroyed so the survivor cannot pin this process's event loop. The outcome is flagged
rather than thrown: a caller's shutdown loop cannot do anything about a survivor except
report it.
The trust file lives at <config>/mcp-trust.json (constant MCP_TRUST_FILENAME). It is read
through a bounded read (MCP_TRUST_CAPS.fileBytes = 1 MiB, MCP_TRUST_CAPS.records = 256).
A missing file is an empty state; an oversized, over-populated, or corrupt one is empty with
a diagnostic, and nothing here ever rewrites it.
A trust record binds three values: projectRoot (the canonical realpath of the project
directory), id (the server id), and digest (the SHA-256 of the canonical declaration).
The digest is computed by mcpServerDigest in config.ts from every field that changes
what runs: id, command, args, cwd, env, and timeoutMs. Change any of them and the record is
stale until the operator re-trusts it.
resolveMcpServers loads both config scopes and attaches a trust status to each declared
server:
-
user-scope: always
{ status: "trusted", actionClass: server.actionClass }. User declarations are trusted by authorship. -
project-scope:
{ status: "untrusted", actionClass: "unknown", reason: ... }when no record exists;{ status: "stale", actionClass: "unknown", reason: ... }when the digest does not match;{ status: "trusted", actionClass: record.actionClass }when it does.
The action class (read, execute, or unknown) determines how the safety net admits the
server's tools. The test in tests/contracts/mcp-config-trust.test.ts verifies that
actionClass is only allowed in user config: a project-scope declaration with
actionClass produces the diagnostic "only allowed in user config; use the project trust
record".
loadMcpServerConfig reads two optional YAML files:
-
User:
<config>/mcp.yaml(constantMCP_USER_CONFIG_FILENAME). -
Project:
.clio-coder/mcp.yaml(constantMCP_PROJECT_CONFIG_RELATIVE_PATH).
The loader is strict: unknown fields, shell strings, escaping paths, and oversized files are
diagnostics that contribute no server. The caps in MCP_CONFIG_CAPS bound every field:
| Cap | Value |
|---|---|
| fileBytes | 256 KiB |
| servers | 32 |
| idChars | 32 |
| commandBytes | 512 |
| args | 64 |
| argBytes | 4096 |
| envEntries | 64 |
| envValueBytes | 4096 |
| cwdBytes | 512 |
| timeoutMs | 900_000 |
Command validation rejects shell executables (bash, sh, zsh, etc.), shell command strings
(space-separated arguments), NUL bytes, and relative paths. Args must be an array of
strings. Env keys must match /^[A-Z_][A-Z0-9_]*$/.
Project-scope cwd is repository-relative and must stay inside the repository, symlinks
included. User-scope cwd is absolute or relative to the config directory; an absolute
directory is its own containment root.
When a user and a project declaration share an id, the user declaration wins and the project declaration is dropped with a diagnostic. A malformed file contributes nothing (fail closed).
The metadata cache (metadata-cache.ts) persists tool catalogs for declared local MCP
servers. A server's tool list is machine state, not operator policy: it costs a process
launch and a handshake to learn, and it is the same answer every session until the
declaration or the server changes.
The cache identity (McpCatalogIdentity) binds projectRoot, scope, declarationPath,
serverId, digest, and cwd. The file name is a SHA-256 of the first four fields; the
digest and cwd are verified from the file body, so a changed declaration replaces its own
catalog rather than stranding the old one on disk forever.
The cache TTL is MCP_METADATA_CACHE_TTL_MS (24 hours). A catalog is a snapshot and nothing
more: it confers no authority. Trust is resolved from the trust file on every session, never
read back from the cache, and a call still validates against the live listing.
readMcpServerCatalog returns a catalog or null. Malformed, oversized, version-mismatched,
identity-mismatched, future-dated, and expired files are all the same answer: a miss. There
is no degraded hit, because every caller of a degraded hit would have to decide separately
whether to trust it.
writeMcpServerCatalog replaces one server's catalog atomically. It returns false rather
than throwing: a cache that cannot be written must never fail the live tool listing that
produced it. A tool the decoder would reject on the way back in is dropped rather than
written, so a round trip is never the thing that invalidates a file.
The gateway tool (src/tools/gateway/index.ts) is composed by registerAllTools in
src/tools/bootstrap.ts. The composition path is:
-
registerAllToolsreceivesdeps.mcpCapabilities(aMcpCapabilitySourceorfalse). - If absent,
createMcpCapabilitySource({ cwd, registry })is called. - The source is passed to
registerCoreTools, which creates the gateway tool viacreateGatewayTool({ registry, mcp }).
When the model calls gateway(op="find"):
-
runFindinsrc/tools/gateway/index.tscallsdeps.mcp.catalog(). -
catalog()inmcp-capabilities.tscallsresolveStates(), which callsresolveMcpServers({ cwd, configDir })to load both config scopes and attach trust status. - For each trusted server,
entriesForreads the recorded catalog viareadMcpServerCatalog(catalogIdentity(declaration)). - The result is merged with registry entries (built-in and extension tools) and returned.
When the model calls gateway(op="call", capability="mcp_<id>__<tool>"):
-
resolveCapabilitychecks the registry; if absent, it callsdeps.mcp.ensure(name). -
ensurecallsownerOf(name)to find the owningServerState, checks trust, and callsdiscover(state). -
discovercallsconnect(state), which callsclientFactory(spec, options)to create theMcpClient, callsclient.initialize(), thenclient.listTools(), and registers each tool in the registry asmcp_<id>__<tool>. - The tool spec's
runmethod callsclient.callTool(tool.name, args, { timeoutMs }). - The result is returned through the registry's
invokepath, which applies the safety net, autonomy mapping, parking for approval, and result shaping.
The execute trust class projects the launch vector to a bash command so the policy engine
applies the shell rules to it, as an extension command does. read runs everywhere and
unknown asks everywhere.
sequenceDiagram
participant Model as Model
participant GW as Gateway Tool
participant SRC as McpCapabilitySource
participant TRUST as Trust Resolvers
participant CFG as Config Loader
participant CL as McpClient
participant SVC as MCP Server Process
Model->>GW: gateway(op="call", capability="mcp_<id>__<tool>")
GW->>SRC: ensure(name)
SRC->>CFG: resolveMcpServers({cwd, configDir})
CFG-->>SRC: ResolvedMcpServers[]
SRC->>TRUST: trustStatusFor(server, projectRoot, state)
TRUST-->>SRC: {status: "trusted", actionClass}
SRC->>CL: createMcpStdioClient(spec, options)
CL->>SVC: spawn(command, args, {cwd, env, detached})
CL->>SVC: JSON-RPC initialize request
SVC-->>CL: initialize response
CL-->>SRC: McpServerInfo
CL->>SVC: JSON-RPC tools/list request
SVC-->>CL: tools array
CL-->>SRC: McpToolListing
SRC->>SRC: registry.register(makeSpec)
GW->>CL: callTool(tool.name, args)
CL->>SVC: JSON-RPC tools/call request
SVC-->>CL: content blocks
CL-->>GW: McpToolCallResult
GW-->>Model: ToolResult
To add a new MCP server:
- Add a declaration to
<config>/mcp.yaml(user scope, trusted by authorship) or.clio-coder/mcp.yaml(project scope, requires explicit trust). - For project scope, run
clio-coder mcp trust <id> --action-class <class>to record the trust decision. The CLI is insrc/cli/mcp.ts. - The gateway picks up the new server on the next
findorcall.
To change the trust model (e.g., add a new action class):
- Add the class to
MCP_TRUST_ACTION_CLASSESintrust.ts. - Update
McpTrustActionClassandMcpTrustStatus. - Update
describeToolandauthorityNoteinmcp-capabilities.tsto describe the new class's behavior. - The
executeclass'ssafetyCallprojection inmakeSpecis the seam for adding similar projections for other classes.
To change the metadata cache behavior:
- Adjust
MCP_METADATA_CACHE_TTL_MSorMCP_METADATA_CACHE_CAPSinmetadata-cache.ts. - The cache identity fields in
McpCatalogIdentityare the seam for adding new binding dimensions (e.g., a git branch or a specific file set).
Tests the full gateway flow: untrusted or stale project servers are listed with the exact
trust remedy and never launched; a trusted one is launched lazily on the first find,
describe, or call that needs it, lists as mcp_<id>__<tool>, calls, and is closed when the
session ends. The trust record's action class is the capability's action class.
Key cases:
-
Structured-only MCP results: a server returning
{ content: [], structuredContent: data }preserves the data through the gateway to the model. -
Bounded results: a 62 KB search result is truncated to
MCP_RESULT_CONTEXT_BYTES(16 KiB) in model context and offloaded to a file for the full text. -
Malformed results: a result with
isError: "true"(a string, not a boolean) produces an unsuccessful gateway call. -
Raw-wire numeric literals: numeric tokens that do not round-trip through JS
Numberare preserved as{$literal: source}tags.
Tests strict config loading and trust resolution. Key cases:
-
User effect class: a user-scope declaration with
actionClass: readis trusted by authorship. A project-scope declaration withactionClassis rejected with "only allowed in user config". -
Stable digest:
parseMcpConfigTextderives a stable digest that matchesmcpServerDigeston the same fields. - Trust binding: a trust record binds to the digest; changing the declaration invalidates the record.
Tests the protocol framer and the one-process client. Key cases:
-
Protocol framing:
createLineFramercorrectly splits byte streams into newline- delimited JSON-RPC messages. -
Spawn and initialize:
createMcpStdioClientspawns the fixture server and completes theinitializehandshake. -
Tool listing and call:
listToolsandcallToolwork against the fixture. -
Process-group teardown: a descendant that ignores SIGTERM is signalled and waited for
during teardown; the outcome reports
{ complete: false, reason: "group-survived-sigkill" }when the group outlives SIGKILL.
Tests the metadata cache integration with the gateway. Key cases:
- Catalog-only discovery: a trusted server that has neither been launched this session nor left a valid catalog is reported as missing, not launched.
- Cached metadata: a recorded catalog provides tool names and schemas without launching the server.
- Scoped refresh: a scoped refresh launches exactly one server and publishes its catalog.
-
The client never restarts a dead server. A server that exits stays dead until the
gateway creates a new client. The
state()method reports the status; the gateway'sconnectmethod checksstate.failurebefore attempting to connect. Do not add auto-restart logic toclient.ts. -
The outbound cap is a fatal failure. Crossing
maxOutboundBytesfails the client withoverloadand callsclose(). This is deliberate: a server that stopped reading its stdin is broken, and retrying would only delay the diagnosis. Do not soften this to a warning. -
The trust digest binds the declaration, not the trust record. Changing any field that
affects what runs (command, args, cwd, env, timeout) invalidates the trust record. The
actionClassin the trust record is the only field that does not affect the digest; it is a policy annotation, not a launch parameter. -
The metadata cache is a snapshot, not an authority. A catalog read off disk says
nothing about whether a process is running. The
McpCatalogProvenancetype ("live" | "cached" | "missing") is deliberately separate fromstatusinMcpServerListing. Do not conflate the two. -
The gateway find does not launch servers. An ordinary
findanswers from recorded catalogs. Only an explicit scoped refresh (gateway(op="find", server="<id>", refresh=true)) connects, and only to the server it names. A restricted surface already suppresses broad MCP discovery. Do not add a launch tocatalog()ormetadata(). -
Server ids may contain
__. The composed tool namemcp_<id>__<tool>uses a longest-prefix rule to resolve ownership. With declarationsaanda__b, the prefix test claimsa__b's capabilities fora. TheownerOffunction inmcp-capabilities.tsimplements this rule; do not replace it with a simplestartsWithtest. -
The config loader rejects shell executables. The
SHELL_EXECUTABLESset inconfig.tsincludes bash, sh, zsh, fish, and others. A command that invokes a shell is a diagnostic, never a guess. This is intentional: the MCP protocol does not use shell execution, and a shell would bypass the env and cwd containment. -
The protocol version is
"2025-06-18". This is the MCP protocol version the client sends in theinitializerequest. If the server negotiates a different version, the client accepts it (the version string is stored inMcpServerInfo.protocolVersionbut not compared against the client's constant). Do not change the constant without coordinating with server implementations.
Source and generation metadata
title: "Domains gateway"
summary: "The MCP gateway: JSON-RPC 2.0 framing over stdio, the one-process client that spawns and manages MCP servers, the trust model for project-declared servers, and the metadata cache for tool catalogs."
sources:
- "src/domains/gateway/mcp/index.ts"
- "src/domains/gateway/mcp/protocol.ts"
- "src/domains/gateway/mcp/client.ts"
- "src/domains/gateway/mcp/config.ts"
- "src/domains/gateway/mcp/trust.ts"
- "src/domains/gateway/mcp/metadata-cache.ts"
- "src/tools/gateway/mcp-capabilities.ts"
- "src/tools/gateway/index.ts"
symbols:
- "createMcpStdioClient"
- "McpClient"
- "McpError"
- "createLineFramer"
- "classifyJsonRpcMessage"
- "encodeJsonRpcMessage"
- "loadMcpServerConfig"
- "resolveMcpServers"
- "trustMcpServer"
- "untrustMcpServer"
- "readMcpTrustState"
- "readMcpServerCatalog"
- "writeMcpServerCatalog"
- "MCP_PROTOCOL_VERSION"
- "mcpServerDigest"
- "canonicalProjectRoot"
- "createMcpCapabilitySource"
- "createGatewayTool"
tests:
- "tests/contracts/gateway-mcp.test.ts"
- "tests/contracts/mcp-config-trust.test.ts"
- "tests/contracts/mcp-stdio-client.test.ts"
- "tests/contracts/mcp-catalog-source.test.ts"
invariants:
- "A project-declared MCP server never launches without an explicit trust record binding its declaration digest to the canonical project root."
- "The MCP client owns exactly one child process and never restarts it; a dead server stays dead until the gateway creates a new client."
- "The outbound queue enforces a byte cap that fails the client with `overload` when the server stops reading its stdin."
- "User-scope declarations are trusted by authorship and carry no trust record; project-scope declarations require one."
validate:
- "pnpm run test:file -- tests/contracts/gateway-mcp.test.ts"Clio Coder · Repository · Website · Documentation
Wiki v0.1 · Developing implementation reference · Source snapshot: 657dce13d. Authored architecture documents define the product contracts.
- Clio Coder GUI Client
- apps / clio-coder-gui
- Apps clio coder gui server
- Apps clio coder gui tests
- apps
- Architecture
- Command-line surfaces
- Core
- Domains agents
- Config Domain
- Context Domain
- Dispatch domain
- Domains evidence
- Domains extensions
- Domains gateway
- domains
- Domains interop
- Domains lifecycle
- Domains memory
- Middleware Domain
- Domains mux
- Domains observability
- Domains plugins
- Prompt Compiler
- Domains providers
- Domains quota
- Domains resources
- Domains safety
- Domains scheduling
- Domains session
- Vendored Tool Registry and Resolution
- Engine
- Engine acp
- Engine apis
- engine
- Entry point
- Interactive
- interactive
- Interactive overlays
- Interactive renderers
- clio-coder wiki
- Scripts
- Contract tests
- Tests extended
- tests
- Tools
- Tools data
- tools
- Tools verify
- Worker runtime