github.com/looprig/mcp is the Model Context Protocol (MCP) module for Looprig. Its main job is an MCP that lets a Harness agent consume existing MCP servers.
Overview
github.com/looprig/mcp is the Model Context Protocol (MCP) module for Looprig. Its main job is an MCP that lets a Harness agent consume existing MCP servers. It also carries a deliberately small MCP surface (pkg/server) and the carbon-collab-mcp command, which Carbon injects into ACP children to expose one collaboration operation; it does not expose Harness agents as general MCP servers. The module wraps the official go-sdk (pinned at v1.7.0) : no SDK type appears in any pkg/... exported API. Only internal/protocol, internal/serverwire, internal/mcptest (and its fixture command), pkg/transport/* and the examples/server and examples/streamable-http programs may import the SDK, and internal/protocol/leakguard_test.go enforces both halves of that rule. Supported MCP spec revisions: 2024-11-05 through 2026-07-28. Released. Carbon and the tests integration lane consume it.
README
mcp
github.com/looprig/mcp is the Model Context Protocol (MCP) module for Looprig. Its main job is an MCP
client that lets a Harness agent consume existing MCP servers. It also carries a deliberately small MCP
server surface (pkg/server) and the carbon-collab-mcp command, which Carbon injects into ACP children to
expose one collaboration operation; it does not expose Harness agents as general MCP servers.
The module wraps the official go-sdk
(pinned at v1.7.0) without leaking its types: no SDK type appears in any
pkg/... exported API. Only internal/protocol, internal/serverwire, internal/mcptest (and its fixture
command), pkg/transport/* and the examples/server and examples/streamable-http programs may import the SDK,
and internal/protocol/leakguard_test.go enforces both halves of that rule. Supported MCP spec revisions:
2024-11-05 through 2026-07-28.
Status
Released. Carbon and the tests integration lane consume it.
Known limits worth knowing before composing it (details in the security section below):
- MCP tool definitions declare materialized high-output capture safety (
tool.CaptureSafetyDeclarer). Adapter text is capped at 64 KiB, but image and audio blocks are bounded only by the clientLimits, which a composer can raise. Composers need a finite materialized capture maximum. - Elicitation gates are opened by the host’s
GateOpener, not by a Session. - The credential-field rejection on elicitation forms is a guardrail, not a security boundary.
Install
go get github.com/looprig/mcp@latest
Packages
| Package | What it is |
|---|---|
pkg/client |
The protocol-neutral MCP client: one initialized peer connected to one server. Catalogs, calls, elicitation, sampling, logging, progress, cancellation, reconnect, compatibility profiles. |
pkg/auth |
OAuth 2.1 + PKCE, dynamic client registration, token storage. Secrets are closure-held. |
pkg/transport/{stdio,streamablehttp,sse} |
Transports. Each wraps an SDK transport and keeps it on the inside. |
pkg/harness |
The optional adapter from MCP capabilities to Harness tools, gates, events, and configuration identity. |
pkg/server |
A small MCP server surface: register product-owned tools and handlers; the package owns framing, capability advertisement, bounds and wire-error classification. |
pkg/collab |
Wire DTOs and framing for the collaboration broker behind carbon-collab-mcp (MessageAgent). Imports neither Harness nor ACP. |
cmd/carbon-collab-mcp |
Exposes Carbon’s one loop-scoped collaboration operation over MCP stdio. Its broker endpoint and capability arrive only through CODERIG_COLLAB_ENDPOINT and CODERIG_COLLAB_TOKEN; arguments are ignored. |
examples/* |
Runnable programs: stdio, streamable-http, server, harness-adoption, acp-passthrough. |
internal/* |
SDK conversion, catalog model, bounds, scheduling, test fixtures. Not importable. |
Harness does not import this module. The TUI knows nothing about MCP. An application composes the three.
Composition
The application owns the wiring. A Manager owns a Session’s bindings; an
Adopter carries Loops to new catalog generations at safe boundaries.
// 1. A binding is a named server definition attached to a scope.
tr, err := stdio.New(stdio.Config{Command: "some-mcp-server", Args: []string{"--stdio"}})
if err != nil {
return err
}
bindings := []mcpharness.Binding{
{
Name: "docs",
Server: client.Definition{Name: "docs", Transport: tr},
Scope: mcpharness.ScopeSession, // shared by the Loops allowed to use it
Visibility: mcpharness.AllLoops(), // ...which is all of them, here
Required: true, // a failure here stops the Session coming up
},
{
Name: "scratch",
Server: client.Definition{Name: "scratch", Transport: other},
Scope: mcpharness.ScopeLoop, // private to one Loop
Loop: plannerLoopID,
Required: false, // a failure degrades only this binding
},
}
// 2. The Manager needs a gate opener (for elicitation), an event publisher, and
// a reporter for out-of-band notices.
mgr, err := mcpharness.NewManager(bindings, mcpharness.Deps{
SessionID: sess.SessionID(),
Gates: myHost, // implements GateOpener — see "Elicitation" below
Events: myPublisher, // implements EventPublisher
Reporter: myReporter, // implements Reporter
})
if err != nil {
return err
}
defer mgr.Close(ctx)
// 3. Start connects every binding. Required failures return a *StartupError.
if err := mgr.Start(ctx); err != nil {
var startup *mcpharness.StartupError
if errors.As(err, &startup) {
// startup.Failures names each binding and its class.
}
return err
}
// 4. StartAdoption watches the Session for idle boundaries and installs
// refreshed toolsets there — never mid-turn.
adopter, err := mgr.StartAdoption(sess, sess)
if err != nil {
return err
}
defer adopter.Close()
// 5. Install the first toolset on a Loop.
if err := adopter.Install(ctx, loopID, "planner"); err != nil {
return err
}
Manager.SessionTools(loopID, loopName) and Manager.LoopTools(loopID) return
the tool.Definitions the composition root hands to Harness. Model-facing names
are mcp____; colliding names fail closed rather than shadow.
Scopes and selectors
- Session-scoped: one logical connection, shared by the Loops a selector
admits —
AllLoops(),Loops(ids...), orNamed(names...). - Loop-scoped: a connection private to one Loop (
Binding.Loop). - Delegation never copies bindings. A delegate owns none of its parent’s Loop-scoped bindings, and sees a Session-scoped binding only if that binding’s own selector names it.
BindSession
NewManager takes a SessionID in Deps, but a Manager may be constructed
before its Session exists — the common case, since a Session’s configuration
fingerprint wants the Manager’s ConfigDigest(). BindSession(sessionID)
attaches it afterwards. ConfigDigest() and ConfigIdentity() are available
before either call, which is what lets an application stamp MCP identity into a
Session’s fingerprint at creation and detect drift at restore — reported as a
session.ConfigMismatchError, never as journal corruption.
Elicitation and the host gate surface
When a server elicits, the adapter translates the request into a Harness gate
envelope and hands it to Deps.Gates (a GateOpener). The host — a TUI, an
HTTP client, a headless policy — renders it and answers. Three modes are
supported: form, confirm, and open-url. The adapter validates the schema, builds
a real gate.FormSchema, and parses the answer back into what the server asked.
Know the seam here. Harness has no public route to open a gate.KindForm
or gate.KindOpenURL gate in a Session’s gate directory — sessionruntime’s
PrepareGateOpen/ActivateGate are internal, and RespondGate translates only
the loop-actor gate kinds. MCP’s initialize-time elicitation also has no Loop to
hang a gate on in the first place. So a host implementing GateOpener is the
contract: the adapter produces a valid, round-trippable Harness gate envelope and
delivers the answer back to the server, but it is the host, not a Session, that
opens it. session.GateHost is the harness-side capability for raising a
host-owned gate; wiring a GateOpener to it is the application’s job and is not
covered by this module’s tests.
Permission gates for MCP tool calls are ordinary Harness gates, opened by the
real loop runner and answered through session.SessionController.RespondGate.
Sampling
Sampling is opt-in and never advertised without a handler: install both a
handler and a SamplingPolicy, or the capability is not offered to the server at
all. Each request is bounded by a depth cap and a concurrency cap and routed
through the application’s policy. See the limits on depth below.
Transports
| Transport | Status | Notes |
|---|---|---|
stdio |
Recommended | Real subprocess: process-group teardown, child reaping, no orphans on crash. RedactedOrigin is basename + arg count. |
streamablehttp |
Recommended | The current MCP HTTP transport. Refuses credential-leaking redirects. |
sse |
Compatibility only, opt-in | The legacy HTTP+SSE transport, for servers that speak nothing else. It must be enabled explicitly through a compatibility profile; it is never selected by default. Prefer streamablehttp. |
All three redact their origins: no argument, path, query, header, or userinfo reaches an origin string or an error.
Security posture
What is guaranteed
-
SDK containment. No consumer is forced to name an SDK type. Enforced by two independent checks in
internal/protocol/leakguard_test.go— an import allowlist and a syntactic exported-API walk — both of which fail under mutation. -
Secrets are not reachable from the exported surface.
pkg/authholds tokens, client secrets, PKCE verifiers, and CSRF state in closures.internal/secrettestis a deliberately hostile reflector — it defeatsCanInterfaceviaunsafe, recurses through pointers and unexported fields, and ignoresString()/Format()— and cannot reach them. -
The open-url
URLreaches no durable record, by any route.gate.OpenURLPayload.URLisjson:"-": the durable codec type has no URL field at all, so the payload journals only a barescheme://hostorigin.javascript:,data:, andfile:are refused, and userinfo is dropped.The public envelope is closed by the same kind of construction rather than by a scan: an open-url gate’s
Promptis host-authored end to end.translateURLdoes not readreq.Messageat all, so there is no server-chosen string anywhere in the durable envelope for a URL — or a barestate=…— to arrive in. A human’s trust decision is made on the validated bare origin, which the adapter derives itself; a renderer labels it and adds its own trust caution. This is the one place a server’s prose is dropped rather than journaled, because it is the one place the design excludes a secret the prose could otherwise re-introduce. -
Origins and events are redacted on every transport, proven per-transport rather than by one shared helper. Event payloads carry no server-authored text into a status;
ConnectionRestoreddeliberately drops server-claimed drift text. -
Bounds are real. Catalogs, prompts, resources, log messages, and JSON depth are bounded, and an over-limit server is refused rather than truncated into something that looks fine.
-
Failure isolation. A required binding’s failure stops its owner coming up; an optional binding’s failure degrades only itself.
-
Turn immutability. A turn runs under the catalog generation it started with. A tool the server removed or re-schema’d underneath a live snapshot returns a structured
ToolUnavailable/ToolSchemaChangedresult — it never ends the turn, gets silently re-pointed, or makes the Session unrestorable.
What is NOT guaranteed
Read this section as carefully as the one above.
-
The credential-field rejection is a good-faith guardrail, NOT a security boundary. Form answers are journaled unredacted, by owner decision, and consistently with
command.UserInput, which journals raw user text. There is no redaction layer behind the check.The rejection is a heuristic over English tokens: a schema-level check (field name, title,
format: password,writeOnly) plus a body scan requiring a credential token paired with a solicitation verb.format: passwordandwriteOnly: trueare the only reliable signals, and they are reliable only because a server volunteered them.It defends against a careless cooperative server — the integrator who adds an
api_keyfield without thinking about where the answer lands. It does not defend against a hostile one. A determined server can still get a secret into the durable journal, by naming a field innocuously (value,mot_de_passe) and asking for the secret in words the token list does not recognize. What the rule buys is that doing so must be deliberate rather than accidental. That is the whole claim. Stronger controls — a human confirmation before a free-text answer is journaled, or a per-binding policy refusing free-text from untrusted servers — do not exist; they are an owner’s decision.The open-url path is not subject to this: nothing a server authors reaches an open-url gate’s durable envelope at all.
-
A server’s own elicitation
Messageis journaled verbatim on a form gate.translateFormputsreq.Messagein the gate’sBody, and it ridesevent.GateOpenedinto the journal. That is deliberate — on a form gate the message is the question, and a journaled answer whose question was dropped is a record nobody can read. It is bounded (4 KiB) and, for free-text forms, scanned by the same credential heuristic described above, with that heuristic’s same limits: it stops a careless server, not a hostile one.A form gate has no durably-excluded secret for a message to re-introduce, which is what makes this different from the open-url case rather than an instance of it. It remains true that a form gate’s body is untrusted, server-controlled text with a durable destination — treat it as such.
-
Sampling depth counts one observable causal link. A chain is attributed only when the host’s handler calls back into this client with the handler’s context — the single link the module can see. A chain that leaves by another door (a server reaching a second host; a host driving an unrelated client without passing the handler’s context) is not depth-bounded; it is bounded only by the concurrency cap. MCP gives an inbound request no correlation to whatever caused it, so this is structural, not an oversight. Attribution is conservative: an ambiguous chain is read as the longer one.
-
Elicitation gates are not opened by a Session. See the seam above. The adapter’s contract ends at
GateOpener; what a host does with the envelope is the host’s business, and this module cannot vouch for it. -
Compatibility tolerances are a decision, not a safety net. A profile that tolerates a server’s drift is an application choosing to accept it. Every tolerance applied is recorded on the catalog, and they are worth reading.
Testing
| Command | What it runs |
|---|---|
make test |
Unit tests. No subprocesses, no network. |
make test-integration |
The integration-tagged tests: real child processes, real pipes, real MCP servers. |
make secure |
gofmt check, vet, staticcheck, gosec, go mod verify, govulncheck. |
Anything that crosses a process boundary is tagged //go:build integration and lives in a *_integration_test.go file, so it is excluded from the default go test ./... run. make secure deliberately does not depend on test-integration: the pre-commit gate stays fast, and CI runs both.
End-to-end tests that need a real Harness rig — real Sessions, real turns, real
delegates — live in the github.com/looprig/tests module. That is why this
module does not depend on storage.
Go 1.26.9 baseline. Verify standalone against the pinned dependencies with
GOWORK=off go test ./...; make check runs the standard CI surface (fmt-check,
vet, staticcheck, gosec, govulncheck, test, build).
Where it sits
Tier 4 in the Looprig graph. Direct Looprig dependencies: core, harness and
inference (the last only in examples/harness-adoption). Consumed by carbon
and tests.
License
Apache License 2.0. See LICENSE.
Install
This server does not publish a one-line install command.
Open the repository installation guide New to MCP? Start with the mcpskill.org guide →