Documentation
¶
Overview ¶
Package mcpharness binds MCP servers into a Harness Session.
It is the only place where the two vocabularies meet: below it, pkg/client speaks MCP and knows nothing about Loops, turns, or gates; above it, Harness speaks tools, permissions, and gates and knows nothing about MCP. Everything this package exports to Harness is protocol-neutral, and no MCP wire type crosses the boundary in either direction (design §pkg/harness).
This file defines the configuration unit: a Binding, which mounts one MCP server under a name, with an owner (its Scope), an audience (its Visibility), and a startup posture (Required).
Index ¶
- Constants
- Variables
- func ToolInvokeIdentity(binding, rawTool string) string
- type Adopter
- type Binding
- type BindingFailure
- type BindingIdentity
- type BindingOp
- type BindingStatus
- type Clock
- type Deps
- type DuplicateModelNameError
- type EventPublisher
- type EventSource
- type GateOpener
- type GateRequest
- type GateResponse
- type LoopControllers
- type LoopSelector
- type Manager
- func (m *Manager) BindSession(sessionID uuid.UUID) error
- func (m *Manager) Close(ctx context.Context) error
- func (m *Manager) CloseLoop(ctx context.Context, loopID uuid.UUID) error
- func (m *Manager) ConfigDigest() string
- func (m *Manager) ConfigIdentity() []BindingIdentity
- func (m *Manager) LoopTools(loopID uuid.UUID) []tool.Definition
- func (m *Manager) Reconfigure(ctx context.Context, ops []BindingOp) error
- func (m *Manager) SessionTools(loopID uuid.UUID, loopName string) []tool.Definition
- func (m *Manager) Start(ctx context.Context) error
- func (m *Manager) StartAdoption(source EventSource, loops LoopControllers) (*Adopter, error)
- func (m *Manager) Status() []BindingStatus
- type Notice
- type NoticeKind
- type Reporter
- type SampleMessage
- type SampleRequest
- type SampleResult
- type SampleRole
- type SamplingPolicy
- type Scope
- type StartupError
- type ToolIdentity
Constants ¶
const CapabilityToolInvoke = "tool.invoke"
CapabilityToolInvoke is the normalized capability kind every external MCP tool emits from preparation. It is a CONSUMER-BOUND kind: the consumer routes it to its own product access source via a gate AccessBinding (the access-profile spec names the product composition root's binding), never to a sandbox profile, and it is never silently mapped to command execution. The gate always resolves it Gated — one combined approval or one persisted rule keyed on the tool identity — so a tool.invoke requirement carries no reusable rule candidates of this adapter's own. The product composition root binds this product-bound kind: (carbon-assembly: tools emit "tool.invoke").
const DefaultElicitationTimeout = 5 * time.Minute
DefaultElicitationTimeout is the overall wall-clock bound on one elicitation.
It is a WALL-CLOCK bound and that is the point (design §Elicitation: "active- time request timeout may pause, but an overall wall-clock deadline remains"). A binding's Timeouts.Request bounds how long a server may take to answer US; this bounds how long a SERVER may keep a question open in front of a person. Without it, a server could park a gate forever — occupying a slot, holding a turn, and outliving anyone's interest in the answer — simply by never withdrawing it.
const DefaultRetirementTimeout = 30 * time.Second
DefaultRetirementTimeout bounds how long a retiring route waits for the turns still using it. See BindingOp and retire.
const IntegrationSource = "mcp"
IntegrationSource is the event.IntegrationStatus.Source every status this adapter publishes carries. It is the namespace a binding name lives in: two integrations may each have a binding called "github", and Source is what tells them apart.
const ToolSource = "mcp"
ToolSource is the external-toolset slot every MCP tool is installed under. A replacement replaces this source's whole generation and never touches a Loop's declared tools or another source's (see loop.ExternalToolset).
Variables ¶
var ErrAlreadyBound = fmt.Errorf("mcp: manager is already bound to a session")
ErrAlreadyBound is returned by a second BindSession.
Rebinding is refused rather than allowed because a Manager's bindings belong to one Session: the connections are live, their elicitations route to one host's gates, and the tool.invoke approvals their calls raise are decided against one host's access source. A Manager that changed Sessions under a running server would move all of that somewhere the user never agreed to.
var ErrAlreadyStarted = errors.New("mcp: manager is already started")
ErrAlreadyStarted is returned by a second Start.
var ErrManagerClosed = errors.New("mcp: manager is closed")
ErrManagerClosed is returned by every operation on a closed Manager.
var ErrNotBound = fmt.Errorf("mcp: manager is not bound to a session; supply Deps.SessionID or call BindSession")
ErrNotBound is returned by an operation that needs a Session from a Manager that has none.
var ErrSamplingDenied = errors.New("mcp: the sampling policy declined")
ErrSamplingDenied is how a SamplingPolicy refuses to spend. Return it, or anything wrapping it, and the server is told the host declined; the request is audited as denied rather than failed.
The distinction is worth the sentinel. A denial is policy working — a host is always entitled to decline to spend, and an operator reading a stream of them is reading a budget being enforced. A failure is the host trying and breaking, which is a defect. A policy that returned an ordinary error for both would make its own budget enforcement indistinguishable from its own bugs.
Functions ¶
func ToolInvokeIdentity ¶
ToolInvokeIdentity is the stable capability identity of one MCP tool (requirement Scope and Match). It is what a persisted allow rule is keyed on, so it must be deterministic across calls for the same tool AND name the binding together with the raw tool: a rule for "search_issues" on the github binding must never satisfy a call to "search_issues" on some other server that happens to use the same word.
The encoding is "mcp:<binding>:<raw-tool>". Both components are already validated identifiers — the binding by client.Name, the raw tool by the catalog's validateRawName — so the identity is bounded, control-free, and carries no argument material (redaction: the arguments never enter it).
Types ¶
type Adopter ¶
type Adopter struct {
// contains filtered or unexported fields
}
Adopter installs each Loop's MCP toolset at that Loop's idle boundary.
It is safe for concurrent use and owns one goroutine, which is the only place a replacement is issued. Serializing the boundaries is deliberate: two idles racing would otherwise have to be told apart by a lock held across a command to a Loop actor, and adoption is rare, cheap, and never on a turn's critical path. Loops still adopt independently — sequential handling orders the work, it does not couple the boundaries.
func (*Adopter) Close ¶
Close stops the Adopter and releases its subscription. It is idempotent and waits for the goroutine, so a caller that returns from Close knows no further replacement will be issued.
func (*Adopter) Install ¶
Install installs the identified Loop's MCP toolset now, without waiting for a boundary.
It exists for the one moment a boundary cannot serve: a Loop's first toolset. A Loop that has never run has never parked, so nothing has signalled an idle, and a Loop's first turn would otherwise run with no MCP tools at all. The caller is the composition root, which knows the Loop is not mid-turn because it has not started it yet.
It is otherwise the same operation as a boundary, including the generation-signature check, so calling it on a Loop that is already current is free and installs nothing.
type Binding ¶
type Binding struct {
// Name is the binding's stable identity within the Session. It must satisfy
// client.Name.
Name string
// Server is the secret-free MCP connection configuration.
Server client.Definition
// Scope names the connection's owner. Required.
Scope Scope
// Loop is the owning Loop, for ScopeLoop only. Harness has no loop.ID named
// type — Loop identity is a bare uuid.UUID throughout (identity.Coordinates,
// tool.Bindings.LoopID) — so this mirrors it rather than inventing an alias
// Harness would not recognize.
Loop uuid.UUID
// Visibility decides which Loops may consume a ScopeSession binding. It is
// unused for ScopeLoop, whose audience is its owner and nobody else.
Visibility LoopSelector
// Required states whether the owner may come up without this binding. A
// required binding must be ready before its owner is; an optional one that
// fails leaves its owner usable and marks only itself failed (design
// §Required and optional servers).
Required bool
}
Binding mounts one MCP server into a Session under a stable name.
The name is part of capability identity: it qualifies every model-facing tool name (mcp__<binding>__<raw>) and every permission identity (mcp:<binding>:<raw-tool>), so two bindings may target the same server executable or URL under different names when they need separate credentials, working directories, or isolation — and they are then genuinely separate authorities.
Required and Visibility live here rather than on client.Definition on purpose: the same server is required in one product and optional in another, visible to every Loop here and to one Loop there, and none of that is a property of the server (design §Binding model and scope).
Treat a Binding as immutable once Validate has returned nil. Reconfiguration creates new Bindings; it never mutates one an active turn may be using.
func (Binding) Validate ¶
Validate checks the binding and fails closed on the first violation.
The scope-shaped rules are the ones worth stating: a Session-scoped binding may not name a Loop, and a Loop-scoped binding must name exactly one and may not carry a selector. Neither is pedantry. A Loop on a Session binding would be an ownership claim the Session does not honor — the field would say "this Loop's connection" while Session shutdown closed it — and a selector on a Loop binding would look like a visibility policy while being silently ignored, which is the shape of an audience mistake nobody catches: it reads as though the binding were shared, and it never is.
Server is validated too. A binding is only as good as the connection under it, and both this Name and Server.Name must agree, because the qualified tool names the model sees are built from the former while the protocol calls go out under the latter — a disagreement would make the reverse mapping a lie.
type BindingFailure ¶
type BindingFailure struct {
// Binding names the binding that failed.
Binding string
// Class classifies the failure.
Class client.FailureClass
// Message is the failure's bounded, normalized text.
Message string
}
BindingFailure is one binding's classified startup failure.
type BindingIdentity ¶
type BindingIdentity struct {
// Name is the binding's stable name within the Session.
Name string
// Scope names the connection's owner.
Scope Scope
// Loop is the owning Loop for a loop-scoped binding, zero otherwise. It is
// the "owner identity" half of the design's "Loop selector identity (or
// owner identity)".
Loop uuid.UUID
// SelectorDigest is the hex digest of a session-scoped binding's Visibility:
// which Loops may consume it. It is empty for a loop-scoped binding, which
// has no selector — its audience is its owner, which Loop names.
//
// It is a digest rather than the membership list because the list is
// unbounded and, for a Named selector, is application vocabulary that has no
// business in a journal. What a restore needs is whether the audience
// changed, and equality answers that.
SelectorDigest string
// TransportKind names the transport, e.g. "stdio".
TransportKind string
// RedactedOrigin is the transport's display origin. It is redacted at the
// source: TransportFactory.RedactedOrigin's contract is that it never
// contains credentials, and this field is that value verbatim.
RedactedOrigin string
// Required reports the binding's startup posture. It is identity because it
// is a behavior contract: the same servers with one flipped from optional to
// required is a Session that now refuses to start.
Required bool
// CapabilityDigest is the hex digest of the client capabilities this binding
// advertises to its server.
CapabilityDigest string
// FilterDigest is the hex digest of the binding's ToolFilter.
FilterDigest string
// LimitsDigest is the hex digest of the binding's declared timeouts and
// limits.
LimitsDigest string
// CompatDigest is the hex digest of the binding's compatibility profile —
// client.Profile.Digest, the checkable form of Status.CompatProfile's label.
CompatDigest string
// Server is what the server claimed to be at initialize. Zero before a
// handshake. Cosmetic in the protocol's terms — it names a peer, it never
// authorizes one — but a change to it is still a change worth reporting.
Server client.ServerIdentity
// ProtocolVersion is the version negotiated at initialize. Empty before the
// handshake.
ProtocolVersion string
// CatalogGeneration is the ordinal of the adopted catalog, 0 before one is
// adopted.
//
// It is reported but NOT digested: a generation number is an ordinal, not
// content. Two runs that discover the same catalog after a different number
// of refreshes have the same configuration, and digesting the ordinal would
// report drift on every reconnect. CatalogDigest is the content.
CatalogGeneration uint64
// CatalogDigest is the hex digest of the adopted catalog — the server's
// whole offering, independent of this host's filter. Empty before one is
// adopted.
CatalogDigest string
// Tools are the tools this binding actually exposes: the catalog projected
// through the ToolFilter, in the catalog's stable order.
//
// Filtered, where CatalogDigest is not, and the pair is the point. The
// catalog digest says what the server offers; these say what this binding
// made of it. A filter change moves these and not the catalog digest, which
// is exactly the distinction a drift report wants to draw.
Tools []ToolIdentity
// Digest is the hex digest of every other field of this identity: the
// binding's whole secret-free identity in one comparable value.
Digest string
}
BindingIdentity is one binding's secret-free identity: the design's configuration-manifest entry (§Session restore and configuration identity).
It mixes two kinds of fact, and the mix is deliberate. The configuration half (Name through CompatDigest) is what the application declared and is knowable before anything connects. The negotiated half (Server through Tools) is what the server turned out to be and is only knowable after discovery, so it is zero on a binding that never became ready. Both belong in the same identity because both are things a session must not silently resume under a change to: a server swapped behind a stable URL is as much a change as a URL swapped.
type BindingOp ¶
type BindingOp struct {
// contains filtered or unexported fields
}
BindingOp is one reconfiguration step. Build one with AddBinding, RemoveBinding, EnableBinding, DisableBinding, or ReplaceBinding.
It is an opaque value rather than an exported struct so that the set of operations stays closed: a caller cannot construct a half-specified op, and adding an operation later cannot silently change the meaning of one already written.
func AddBinding ¶
AddBinding starts a new binding under a live owner.
func DisableBinding ¶
DisableBinding retires a binding's connection but keeps its configuration, so EnableBinding can start it again.
func EnableBinding ¶
EnableBinding starts a disabled binding again.
func RemoveBinding ¶
RemoveBinding retires a binding and forgets its configuration.
func ReplaceBinding ¶
ReplaceBinding swaps a binding's transport, auth, limits, filters, or server identity. The new client connects before the old route is retired, so a replacement that fails costs nothing: the prior binding stays active and the failure is reported (design §Binding reconfiguration).
func (BindingOp) FailClosed ¶
FailClosed returns a copy of a replace op that retires the prior binding even when the replacement fails.
It is the deliberate opposite of the default. Leaving the prior binding up is right when a replacement is an upgrade — losing the new configuration beats losing the server. It is wrong when the replacement exists to REVOKE something: narrowing a tool filter, rotating a credential, moving to an endpoint the old one must no longer reach. There, a failure that silently leaves the old authority serving is the failure, so the caller says so here.
It has no effect on any other op kind: add, remove, enable, and disable have no prior binding to keep.
type BindingStatus ¶
type BindingStatus struct {
// Name is the binding name.
Name string
// Scope names the connection's owner.
Scope Scope
// Loop is the owning Loop for a loop-scoped binding, zero otherwise.
Loop uuid.UUID
// Required reports the binding's startup posture.
Required bool
// Enabled is false for a binding an operator disabled.
Enabled bool
// Retiring reports that the binding is out of future generations and is
// serving only the turns that still hold it.
Retiring bool
// Client is the connection's status, already redacted and bounded. It is
// the zero value with State StateConfigured before a connection exists.
Client client.Status
}
BindingStatus is a binding's observable state: its configuration posture plus its connection's status. Every field is safe metadata (design §Lifecycle and readiness) — it is designed to be logged, rendered, and shipped to telemetry as-is.
type Clock ¶
Clock reports the current time. It exists so tests can drive timeouts and timestamps deterministically rather than sleeping.
type Deps ¶
type Deps struct {
// SessionID identifies the Session these bindings belong to. It is the
// coordinate every event.IntegrationStatus is stamped with, and an event
// carrying the wrong Session's ID — or none — is one that either reaches the
// wrong subscribers or is refused as invalid. The Manager cannot derive it:
// it is deliberately given a GateOpener and an EventPublisher rather than a
// Session (see this file's header), so nothing it holds knows the answer.
//
// It is OPTIONAL, and the zero value is a supported way to build a Manager
// rather than a mistake: an application that contributes its MCP identity to
// the Session's config fingerprint must discover its servers BEFORE the
// Session exists, so there is no ID to give yet. Such an application leaves
// this zero and calls BindSession once rig.NewSession has minted one. See
// attach.go, which owns that ordering and is the only reason this field is
// optional.
//
// Supplying it here is still the right thing for an application that does not
// contribute a fingerprint: the Manager is attached from birth and never has
// a window in which a status has nowhere to go.
SessionID uuid.UUID
// Gates routes elicitation to a human. Required: a binding that advertises
// the elicitation capability with nowhere to ask would be a lie told to a
// server.
Gates GateOpener
// Events receives integration events. Required: a binding whose failures
// nobody can observe is one an operator cannot fix.
Events EventPublisher
// Sampling services servers' requests for LLM completions, under the
// application's own policy. Optional, and its absence is the default: this
// module never spends a host's model budget because it was composed, only
// because it was asked to.
//
// # Nil means the capability is not advertised, and that is not a downgrade
//
// A nil here yields a nil client.Handlers.Sampling for every binding (see
// Manager.samplingHandler). That is the whole of the fail-closed rule, and it
// is deliberately NOT a rule this file enforces a second time:
// client.Handlers.advertised already owns it, and owns it for elicitation and
// roots identically — a capability the Definition requests with no handler to
// serve it is a FailureInvalidConfig, never a silent downgrade. So a Binding
// whose Server.Capabilities.Sampling is true with no policy installed fails to
// connect, before its transport is ever dialed, with the same classified error
// an application would get from pkg/client directly. A required binding takes
// its owner's startup down with it; an optional one fails as a whole, serving
// nothing — never a binding that quietly comes up with its tools and without
// the capability it advertised.
//
// Re-deriving that check here would be a second opinion on a decision one
// package already makes: two places to keep in step, and the wrong one wins
// whenever they disagree.
//
// # Why this is not a field on Binding
//
// Sampling authority IS per-binding, and it already lands there: a binding
// advertises the capability only if its own Server.Capabilities.Sampling says
// so, and SampleRequest.Binding names the asker, so one policy decides per
// server. What a Binding must not hold is the policy VALUE. A Binding is
// immutable configuration that gets digested into the Session's configuration
// identity (see identity.go), and an interface has no stable encoding to
// digest — it would either be excluded, making the digest lie about what a
// binding may spend, or included, making a restore report drift because the
// host allocated a new policy. Deps is where host capabilities live, for
// exactly this reason: Gates is not a Binding field either.
Sampling SamplingPolicy
// Reporter receives the adapter's own notices — the facts the sealed
// Harness event set cannot express. Optional; nil drops them.
Reporter Reporter
// Clock is the time source. Nil selects the system clock.
Clock Clock
}
Deps are the host capabilities the Manager needs. Gates and Events are required; the rest select documented defaults when nil.
type DuplicateModelNameError ¶
type DuplicateModelNameError struct {
// ModelName is the contested model-facing name.
ModelName string
// Binding is the binding whose tool was rejected.
Binding string
// OtherBinding is the binding that already holds the name. It equals
// Binding when one binding offered the name twice, which a catalog should
// have prevented; the table still refuses it.
OtherBinding string
}
DuplicateModelNameError reports two tools that claim one model-facing name.
It is a typed error because the Manager must classify it rather than merely report it: the correct response is to refuse the offending binding and mark it degraded, leaving the binding that already holds the name serving. A caller that could only render this would have to choose between failing the whole Session and shipping an ambiguous namespace.
Every field is a name — a binding name (validated by client.Name) or a ModelName (already sanitized to [a-zA-Z0-9_-] by the catalog) — so the message carries no server-controlled bytes and nothing to redact.
func (*DuplicateModelNameError) Error ¶
func (e *DuplicateModelNameError) Error() string
type EventPublisher ¶
EventPublisher receives the adapter's protocol-neutral integration events.
The method is named PublishEvent so that *hub.Hub satisfies this interface structurally, with no adapter in between — the same deliberately structural coupling harness uses for its CommandRunner seam. The adapter still declares the interface, so it depends on the one method it calls rather than on the Hub.
type EventSource ¶
type EventSource interface {
SubscribeEvents(event.EventFilter) (event.Subscription, error)
}
EventSource is the Session's event stream, narrowed to the one method the adapter calls.
The signature is session.Session's rather than *hub.Hub's: the Hub's SubscribeEvents returns its own concrete *hub.EventSubscription, so a consumer-side interface naming event.Subscription is not satisfied by the Hub structurally, while every session.Session is. That is the right way round — the Session is the public contract and the Hub is its machinery — but it does mean a host holding only a Hub must pass a two-line adapter. See the report accompanying this stage.
type GateOpener ¶
type GateOpener interface {
OpenGate(ctx context.Context, req GateRequest) (GateResponse, error)
}
GateOpener opens a Harness gate and blocks until it is resolved.
It must honor ctx: the adapter cancels a pending elicitation when its MCP request is cancelled or its binding shuts down, and a host that ignored that would strand a server request nobody can answer.
type GateRequest ¶
type GateRequest struct {
// Kind is the gate scenario: gate.KindForm or gate.KindOpenURL.
Kind gate.Kind
// Payload is the authoritative request. It is a sealed gate.Payload, so a
// host cannot be handed a shape the gate codec would reject.
Payload gate.Payload
// Prompt is the presentation projection an opener renders.
Prompt gate.Prompt
// Restorable states whether the gate may survive a restore boundary. An
// open-url gate must never be restorable — its action target is
// deliberately not journaled, so a restored gate could only ever be a
// broken one, and gate.ValidateGate rejects it (see gate.OpenURLPayload).
Restorable bool
// Binding names the MCP binding that asked, for attribution.
Binding string
// LoopID is the Loop the request is on behalf of, or zero for a
// Session-scoped binding's own startup — an elicitation raised during
// initialization belongs to no Loop yet.
LoopID uuid.UUID
}
GateRequest asks the host to put a question to a human (or to a policy that answers for one). It is the adapter's whole human-input vocabulary: an MCP elicitation becomes one of these, and nothing MCP-shaped goes any further (design §Elicitation).
type GateResponse ¶
GateResponse is how the gate was answered. Action is one of the gate.FormAction* values; Values carries form answers and is nil for anything but an accepted form.
type LoopControllers ¶
type LoopControllers interface {
LoopController(uuid.UUID) (loop.Controller, bool)
}
LoopControllers resolves a Loop's control surface. It is satisfied by harness's session.SessionController, and it is the narrowest thing that will do: the adapter needs to reach one Loop's installer, not to drive a Session.
type LoopSelector ¶
type LoopSelector struct {
// contains filtered or unexported fields
}
LoopSelector decides which Loops may consume a Session-scoped binding. It is an immutable value: build one with AllLoops, Loops, or Named and copy it freely.
The zero value permits no Loop. That is deliberate — see the file comment.
func AllLoops ¶
func AllLoops() LoopSelector
AllLoops permits every Loop in the Session, including Loops created later.
func Loops ¶
func Loops(ids ...uuid.UUID) LoopSelector
Loops permits exactly the Loops with these IDs.
Identity is the precise selector: a Loop ID is minted by the Session and cannot be spoofed by a delegate that happens to share a name.
func Named ¶
func Named(names ...string) LoopSelector
Named permits exactly the Loops with these names, matched exactly and case-sensitively (as pkg/client's ToolFilter matches tool names).
Names are how a composition root talks about Loops it has not created yet, so this is the selector for static configuration. It is weaker than Loops: a name is a label the application chose, not an identity the Session minted.
func (LoopSelector) Permits ¶
func (s LoopSelector) Permits(loopID uuid.UUID, name string) bool
Permits reports whether the Loop identified by loopID and name may consume the binding. It never returns true for a selector nobody built.
A Loop must present both identifiers because the caller does not know which kind of selector it is asking; the selector uses the one it selects on and ignores the other. An empty name never matches a named selector even if the selector somehow lists an empty name, and a zero loopID never matches an ID selector: absent identity is not identity.
func (LoopSelector) String ¶
func (s LoopSelector) String() string
String returns a bounded, safe description for diagnostics. It names the mode and the population size, never the members: a selector may list dozens of IDs and this is a log line, not a dump.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager owns a Session's MCP bindings.
It is safe for concurrent use. Its own mutex guards only the binding table — which bindings exist — and is never held across a connect, a close, or a call into host code, so a server that elicits during initialization can be answered while Start is still waiting on some other required server.
func NewManager ¶
NewManager validates the bindings and returns a Manager that has not connected anything yet.
Every binding is validated up front, and duplicate names are rejected: a binding name qualifies tool identities and permission identities, so two bindings sharing one would make both ambiguous — the model would see one name for two authorities.
func (*Manager) BindSession ¶
BindSession attaches a Manager to the Session its bindings serve, and is how an application that discovered its servers BEFORE creating that Session closes the loop. See this file's header for the ordering it exists for.
It is idempotent in neither direction: a Manager built with a Deps.SessionID is already bound and returns ErrAlreadyBound, as does a second call.
On success it republishes every binding's current status, so that the Session's event stream opens knowing what each server actually is — including the ones that reached their final state while there was still no Session to tell.
func (*Manager) Close ¶
Close shuts the whole Manager down: every binding, whatever its scope.
It cancels startup, in-flight requests, pending elicitations, background readers, reconnect work, and stdio subprocesses by cancelling the Manager's lifetime context, then closes each connection and waits — within ctx's bound — for the background goroutines to finish (design §Shutdown). It is idempotent: a second Close returns nil without touching anything.
func (*Manager) CloseLoop ¶
CloseLoop closes the bindings the identified Loop owns.
It matches on ownership, which is what makes design §Delegation hold without a special case: a parent's shutdown never reaches a delegate's bindings, because those name the delegate's Loop as their owner. Session-scoped bindings are untouched however many of this Loop's turns were using them — the Session owns those, and another Loop may be mid-call on one right now.
func (*Manager) ConfigDigest ¶
ConfigDigest returns the hex digest of the whole MCP configuration identity: the value a composing application stamps into event.ConfigFingerprint.ExternalCapabilityRev.
It is empty for a Manager with no bindings. That is the contract the fingerprint field needs rather than an accident: the field's meaning is "empty = no external capability", so an application that configures no MCP at all must produce the empty string and compare Equal to a journal written before the field existed. A Manager with bindings can never digest to empty — no preimage for the empty string is known, because it is not a digest.
See ConfigIdentity for why this is taken after Start.
The restore flow ¶
Harness does not call this. Nothing in this module knows what a Session is, so the composition root — which owns both the Manager and the Session — is what joins them, and the whole protocol is:
- Recreate the Manager from the CURRENT application configuration. Never from journal bytes: MCP connections are live resources, and a stdio child or an OAuth token from last week is not a thing to reconstitute (design §Session restore).
- Start it, so the negotiated half of the identity is real — the servers' catalogs are what they are today, not what they were.
- Take ConfigDigest and stamp it into the ConfigFingerprint's ExternalCapabilityRev, alongside the model, prompt and tool-policy revs the application already supplies.
- Restore the Session. Harness compares the whole fingerprint against the one stamped at SessionStarted and rejects a mismatch with a typed *ConfigMismatchError. An application that means to resume across a change passes sessionruntime's WithAllowConfigMismatch.
Step 4 is the entire drift report, and it is deliberately blunt: one boolean decision over one equality. There is no per-binding drift here, no adopted epoch, and no "which server changed" — the design that specifies those (2026-07-16-session-versioning-migration-design.md) is not implemented, and a richer report from this module would be a shape Harness has nothing to do with. ConfigIdentity is what an application renders when it wants to TELL a human what moved; the digest is what the machine decides on.
What drift does not do ¶
It does not make the journal unreadable. Changing servers, tools, schemas, auth posture, transports, or filters is a configuration change, not a migration: historical MCP calls in the journal remain data, and a tool that no longer exists yields a structured ToolUnavailable result on the next call rather than an unrestorable Session (design §Session restore). Accepting drift is therefore a normal thing to do, which is why the override is a boolean and not a repair.
func (*Manager) ConfigIdentity ¶
func (m *Manager) ConfigIdentity() []BindingIdentity
ConfigIdentity returns the secret-free identity of every binding, in deterministic name order.
It is a snapshot, like Status: it reads what each binding is right now, so a binding that has not finished discovery reports its configuration half and a zero negotiated half. An application comparing identities across a restore therefore takes it after Start — comparing a pre-discovery identity against a post-discovery one reports drift that is only earliness.
After Start is not the same as after every binding has settled, and the gap is Required's: Start waits for the required bindings and returns while the optional ones are still connecting (see Start). So an optional binding may legitimately report a zero negotiated half from a Manager that is fully started, and a digest taken twice at different moments may differ for no reason but timing. That is a property of optional bindings rather than a defect here — an optional server is one the owner does not wait for, so its catalog is not something the owner can be said to have started under — but an application that stamps a fingerprint from bindings it expects to be discovered should mark them Required, which is what "the owner may not come up without this" already means.
func (*Manager) LoopTools ¶
func (m *Manager) LoopTools(loopID uuid.UUID) []tool.Definition
LoopTools returns the external tool definitions from the bindings the identified Loop owns. A delegate never appears here for its parent's private bindings — it was never their owner (design §Delegation).
func (*Manager) Reconfigure ¶
Reconfigure applies ops to a live Manager.
Every op is validated before any is applied, so a batch with a malformed op changes nothing: a caller that mistyped one binding's name should not discover it with three servers already reconnected. Beyond that the ops apply in order and their failures are joined — an op that fails leaves its own binding as it was, and does not stop the rest.
func (*Manager) SessionTools ¶
SessionTools returns the external tool definitions the identified Loop may consume from the Session's shared bindings.
loopName is matched by a Visibility that names Loops rather than IDs; a Loop with neither an ID nor a name a selector permits sees nothing, because the zero LoopSelector permits nothing (selector.go).
func (*Manager) Start ¶
Start connects every binding concurrently and returns once the required ones have settled.
The concurrency is the contract, not an optimization (design §Concurrent startup): one slow optional server must not delay an unrelated one's discovery, and Start must not hold anything a callback needs — a server may elicit or authenticate during its own initialization, and the answer has to route while startup is still in progress. That is why the binding table's lock is released before the wait and why every per-binding fact lives under the binding's own lock.
It returns a *StartupError naming every required binding that failed. An optional binding that fails degrades only itself and leaves the owner usable. Optional bindings that have not settled keep connecting in the background after Start returns; Close waits for them.
func (*Manager) StartAdoption ¶
func (m *Manager) StartAdoption(source EventSource, loops LoopControllers) (*Adopter, error)
StartAdoption subscribes to the Session's idle boundaries and installs each Loop's toolset as they arrive.
It does not install anything for a Loop that is already running: a Loop's first toolset is the application's to install, at composition time, with Install. This only reacts to boundaries.
Close stops it. The Manager's own Close does not: an Adopter is an optional capability layered over a Manager, and a Manager that closed its host's subscription would be reaching outside what it was given.
It refuses a Manager that is not bound to a Session (ErrNotBound). That is where the check for a forgotten BindSession belongs: adoption is the point at which a Manager stops being a set of connections and starts serving a Session's Loops, so an unbound Manager here is one whose every integration status would be dropped for the life of that Session — see attach.go. Deps.SessionID cannot fail closed at construction instead, because "not yet" is a legitimate answer there and is the whole reason this seam exists.
func (*Manager) Status ¶
func (m *Manager) Status() []BindingStatus
Status returns a snapshot of every binding, in a deterministic name order so that a rendering of it does not reshuffle between reads.
type Notice ¶
type Notice struct {
// Kind classifies the notice.
Kind NoticeKind
// Binding names the binding concerned.
Binding string
// LoopID is the Loop concerned, or zero when the notice is not about one.
LoopID uuid.UUID
// Generation is the catalog generation concerned, or 0.
Generation uint64
// Message is bounded, redacted explanatory text.
Message string
}
Notice is one thing the adapter needs an operator to be able to see.
It exists because the design's "reports" cannot be an event.Event: the Harness event set is sealed (Event.isEvent is unexported), so no external module can add a member, and none of the existing members can express "this binding's tools were refused for this Loop". Deps.Events therefore carries the events Harness itself defines, and this carries the facts only MCP knows. The alternative — inventing a Harness event type for a protocol Harness must not know about — is exactly what this module's boundary exists to prevent.
Every field is safe to log as-is: names are validated identifiers and Message is this module's own bounded text.
type NoticeKind ¶
type NoticeKind uint8
NoticeKind classifies an adapter notice. The zero value is not a kind.
const ( // NoticeToolNameCollision reports a binding refused from a Loop's // namespace because one of its model-facing names is already held by // another binding. The offending binding contributes nothing to that Loop; // the incumbent keeps serving. NoticeToolNameCollision NoticeKind = iota + 1 // NoticeAdopted reports that a Loop's toolset was replaced with a newer // catalog generation at its idle boundary. NoticeAdopted // NoticeAdoptionFailed reports that a replacement was refused. The Loop // keeps the generation it had; nothing partial was installed. NoticeAdoptionFailed // NoticeAdoptionUnsupported reports a Loop that cannot host external tools // at all — a foreign loop, whose toolset belongs to its foreign agent. It // is a permanent property of the Loop, not a failure to retry. NoticeAdoptionUnsupported // NoticeEventRejected reports an integration status that could not be // published: this adapter built one the event contract refuses, or the host // declined it. It is the sink of last resort — the fact that the sink for // facts is broken has nowhere else to go. NoticeEventRejected // NoticeElicitationDeclined reports a server's request for human input that // was refused without ever being shown to anyone: an unsupported schema, a // field soliciting a credential, an unusable URL. The server is told // "decline"; this says why, which the server is deliberately not told. NoticeElicitationDeclined // NoticeSamplingRequested reports that a server asked the host to spend // model budget. It is raised when the request ARRIVES, not when it is // admitted: a request the client's depth or concurrency cap refused is // exactly the one an operator wants to see. // // It carries the request's shape — how many messages, what budget, what // chain depth — and none of its content. See sampleAudit. NoticeSamplingRequested // NoticeSamplingResolved reports how a sampling request ended. Exactly one // follows every NoticeSamplingRequested, including for the requests refused // before any model ran. NoticeSamplingResolved // NoticeSamplingDenied reports WHY the host refused, which the server is // deliberately not told — the same rule NoticeElicitationDeclined follows, // for the same reason: a server that can enumerate a host's policy can // search for a request that gets past it. NoticeSamplingDenied )
func (NoticeKind) String ¶
func (k NoticeKind) String() string
String returns a stable lowercase identifier, or "unknown".
type Reporter ¶
type Reporter interface {
Report(Notice)
}
Reporter receives the adapter's notices. It is optional; a nil Deps.Reporter drops them.
Report must not block: it is called on the goroutine that discovered the fact, which may be a Loop's adoption path or a live toolset assembly. Hand off anything expensive.
type SampleMessage ¶
type SampleMessage struct {
// Role is who the server says authored this message. It is always a
// declared role.
Role SampleRole
// Text is the message body, bounded by the binding's Limits.
Text string
}
SampleMessage is one turn of a sampling conversation. Both fields are the server's: it chose the text and it chose whom to attribute it to. Neither is an instruction to the host.
type SampleRequest ¶
type SampleRequest struct {
// Binding names the server that asked. It is the whole of "which authority
// is this": a policy that trusts one server to spend and not another
// switches on this.
Binding string
// LoopID is the Loop the request is on behalf of, or zero for a
// Session-scoped binding.
//
// The zero is not a gap, for the reason GateRequest.LoopID gives: a
// Session's server is shared, and a sampling request it raises belongs to the
// Session rather than to whichever Loop happened to be calling. A policy that
// budgets per Loop must therefore treat the zero as "the Session's own
// budget" rather than as an unattributed request to be waved through.
LoopID uuid.UUID
// SystemPrompt is the system prompt the server asked for, bounded. It is a
// request, not a fact: a policy is free to replace or ignore it.
SystemPrompt string
// Messages is the conversation the server wants completed. Never empty:
// pkg/client refuses a sampling request with no messages before it reaches
// here.
Messages []SampleMessage
// MaxTokens is the completion budget, already capped against the binding's
// Limits.MaxSamplingTokens. A server may lower this ceiling and never raise
// it, so it is an upper bound the policy may spend within — not a number to
// honor.
MaxTokens int
}
SampleRequest is a server asking the host to spend model budget on its behalf.
Every field but Binding and LoopID is server-supplied and untrusted. The server's own steering — its model preferences, its temperature, its request to have the host harvest conversation context — is dropped by pkg/client before this exists, so what arrives here is the request reduced to what a host can safely act on: some text, and a ceiling.
Why this type has nowhere to put a reference ¶
Every field is a string, an int, or an array of bytes. That is the design's "sampling never receives a Harness Session controller or unrestricted tool registry", enforced structurally: a SamplingPolicy cannot be handed authority through this type because there is no field shaped like authority, and a field added later must argue with TestSamplingSeamCarriesNoAuthority, which sweeps this struct by field KIND. pkg/client holds up the same end for its own SampleRequest, and internal/protocol registers only the SDK's tool-free handler — so a tool cannot enter from the server's side either.
type SampleResult ¶
type SampleResult struct {
// Model names the model the policy actually ran, which need not be one the
// server asked for. It is required: pkg/client refuses a result without one,
// because a completion nobody can attribute to a model is not auditable.
Model string
// Text is the completion.
Text string
// StopReason is why generation stopped.
StopReason string
}
SampleResult is the completion the host's policy produced.
type SampleRole ¶
type SampleRole uint8
SampleRole is who authored a sampling message. The zero value is not a role.
const ( // SampleRoleUser marks a message the server attributes to the user. SampleRoleUser SampleRole = iota + 1 // SampleRoleAssistant marks a message the server attributes to the model. SampleRoleAssistant )
The sampling message roles.
func (SampleRole) String ¶
func (r SampleRole) String() string
String returns a stable lowercase identifier, or "unknown".
type SamplingPolicy ¶
type SamplingPolicy interface {
Sample(ctx context.Context, req SampleRequest) (SampleResult, error)
}
SamplingPolicy services a server's request for an LLM completion, under the application's policy and nobody else's.
What the adapter does and does not decide ¶
Server-requested sampling gives a server authority to initiate model work and spend (design §Optional sampling). The application supplies model selection, budget, permission, recursion, tool-use, and content policy; this adapter invents none of it. It routes the request, attributes it to a binding and a Loop, translates the answer, and audits both — and that is the whole of its job. There is deliberately no default: a nil Deps.Sampling is not a permissive policy or a restrictive one, it is no policy, and a binding that asked to advertise the capability without one fails to start (see Deps.Sampling).
Sample is called on the connection's goroutine while the server waits. It must honor ctx, which already carries the binding's request timeout and dies with the connection: a policy that ignored it would pin a dispatch goroutine and a sampling slot on a completion nobody is waiting for any more.
The caps that bound how MUCH a server may ask for — tokens, concurrency, chain depth — are pkg/client's and are enforced before this is called, so a policy never has to implement them to be safe. What a policy adds is whether this spend is one the application wants to make at all.
type Scope ¶
type Scope uint8
Scope is the owner of a binding's connection: who created it and whose shutdown closes it. The zero value is not a valid scope.
Scope is not visibility. A Session-scoped connection may be shown to one Loop or to all of them (see LoopSelector); what Scope fixes is that the Session, not the Loop, owns the process, the credentials, and the server's state (design §Binding model and scope).
const ( // ScopeSession mounts the server on the Session. One connection serves // every Loop the binding's Visibility permits, and it closes when the // Session closes. ScopeSession Scope = iota + 1 // ScopeLoop mounts the server on exactly one Loop. The connection is that // Loop's alone and closes when the Loop closes. ScopeLoop )
type StartupError ¶
type StartupError struct {
Failures []BindingFailure
}
StartupError reports every required binding that did not become ready. It is aggregated rather than first-wins because a user fixing their configuration should see all of it at once: three servers with three different problems is three rounds of trial and error if they are reported one at a time (design §Required and optional servers).
func (*StartupError) Error ¶
func (e *StartupError) Error() string
type ToolIdentity ¶
type ToolIdentity struct {
// RawName is the server's own name for the tool.
RawName string
// ModelName is the sanitized, binding-qualified name a model sees. It is
// separate identity from RawName because the mapping is this host's, not the
// server's: two hosts can see the same raw tool under different model names,
// and a model name changing under a stable raw name is a change to what the
// model was shown.
ModelName string
// InputSchemaDigest is the hex digest of the tool's input schema.
InputSchemaDigest string
// OutputSchemaDigest is the hex digest of the tool's output schema, empty
// when the tool has none.
OutputSchemaDigest string
}
ToolIdentity is one tool's contribution to a binding's identity: what it is called, on the wire and to a model, and the shape of its interface.
The schemas are present as digests, never as documents. That is the design's rule for the manifest and it is also the only form that is useful here: a manifest is compared, not read, and a schema document is unbounded input from an untrusted server.