Documentation
¶
Overview ¶
Package-internal cache for MCP handshake results. The handshake (initialize + listTools, plus optional listPrompts/listResources) costs hundreds of ms to a few seconds per server on cold start. We persist the tool schema + capabilities under the user cache dir keyed by a fingerprint of the load- bearing Spec fields, so the next launch can register tools optimistically without waiting for the network/subprocess. Caching is purely an optimisation: any failure (missing dir, bad JSON, hash mismatch) silently degrades to a fresh handshake.
Lazy-tier MCP placeholder tools. A "lazy" plugin registers cheap placeholder entries in the tool registry at boot — using the on-disk schema cache when it exists — and defers the actual subprocess spawn / handshake to the first model call. A "background" plugin is identical except it also kicks the spawn off at boot so by the time the model calls, the swap is already done.
Why the indirection: a lazy/background server still needs stable placeholder tools before the real handshake finishes. Once it does finish, lazySpawn swaps the placeholders for real tools through tool.Registry's own lock, so the next model request sees the real schemas without waiting for another placeholder Execute call.
Package plugin is fairpeer's MCP client. It connects to external MCP servers and adapts their tools to the tool.Tool interface, so the agent treats plugin tools and built-ins uniformly. The wire protocol is JSON-RPC 2.0 in every case; only the transport differs (stdio subprocess, Streamable HTTP, or the legacy HTTP+SSE). A transport interface hides that difference so the MCP-level logic — handshake, tools/list, tools/call — is written once.
Per-plugin startup latency tracking for MCP servers. fairpeer uses these samples to decide whether a chronically slow plugin should be demoted from "eager" to "lazy" loading for the rest of a session — see Recommend.
Storage is one tiny JSON file per plugin under <cacheDir>/mcp/, written atomically (tmpfile + Rename) so a crash mid-write can't corrupt history. All errors are best-effort: missing/unreadable files yield "no demote", write failures get logged via slog and dropped — startup must not fail because telemetry can't persist.
Index ¶
- func DefaultStartupBudget() time.Duration
- func LazyToolset(spec Spec, cs *CachedSchema, host *Host, reg *tool.Registry, ...) []tool.Tool
- func RecordStartup(name string, dur time.Duration) error
- func SaveCachedSchema(name string, cs CachedSchema) error
- func SpecFingerprint(s Spec) string
- func StartAvailableInto(ctx context.Context, h *Host, specs []Spec) []tool.Tool
- func ToolPrefix(server string) string
- type CachedSchema
- type CachedTool
- type Client
- type Failure
- type Host
- func (h *Host) Add(ctx context.Context, s Spec) ([]tool.Tool, error)
- func (h *Host) ClearFailure(name string)
- func (h *Host) Close()
- func (h *Host) Failures() []Failure
- func (h *Host) Prompts() []Prompt
- func (h *Host) ReadResource(ctx context.Context, server, uri string) (string, error)
- func (h *Host) RecordFailure(s Spec, err error)
- func (h *Host) Remove(name string) (toolPrefix string, found bool)
- func (h *Host) Resources() []Resource
- func (h *Host) ServerNames() []string
- func (h *Host) Servers() []ServerStatus
- func (h *Host) StartPhaseB(ctx context.Context, sink event.Sink)
- type Prompt
- type PromptArg
- type Recommendation
- type Resource
- type ServerStatus
- type SessionExpiredError
- type Spec
- type StartPolicy
- type StartupStats
- type ToolInfo
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func DefaultStartupBudget ¶
DefaultStartupBudget is the per-plugin latency budget used by boot when deciding whether to auto-demote (see Recommend). Kept here rather than in stats.go because it's the value boot.go pairs with each Recommend call.
func LazyToolset ¶
func LazyToolset(spec Spec, cs *CachedSchema, host *Host, reg *tool.Registry, sessionCtx context.Context, kick bool) []tool.Tool
LazyToolset returns the placeholder tools to register for one lazy/background spec. When cs is non-nil (cache hit) the returned slice has one lazyTool per cached tool, carrying the cached schema so the model can pass real args; the first Execute runs the handshake synchronously and swaps in real tools. When cs is nil (cache miss) the returned slice has a single stub named "mcp__<server>__connect": the model can call it to drive the spawn, and the real tools surface on the next turn.
kick=true (background tier) also fires off the spawn immediately, so an idle session warms up without waiting for the first model call.
host is the Host that receives the real Client. reg is the registry where real tools land after a successful spawn. sessionCtx must outlive any single Execute (use the controller's PluginCtx) — a turn-scoped ctx would kill the stdio child between turns.
func RecordStartup ¶
RecordStartup appends one sample (the wall-clock duration of a plugin's blocking handshake phase) to that plugin's stats file. The file is created on first call; existing samples are kept in a rolling window of maxSamples, dropping the oldest when full.
Best-effort: any I/O or marshal failure is logged with slog.Warn and returned, but callers are expected to ignore the error — telemetry must never block real work. Writes go through a tmpfile + Rename so a partial write can't leave the stats file truncated or unparseable.
func SaveCachedSchema ¶
func SaveCachedSchema(name string, cs CachedSchema) error
SaveCachedSchema atomically writes cs under name. Best-effort: an error is logged at debug level and dropped. Uses tmpfile + os.Rename in the parent dir so a crash mid-write can't leave a half-written JSON behind that the next Load would mis-parse.
func SpecFingerprint ¶
SpecFingerprint hashes the load-bearing parts of a Spec so changing the command/url/args/env (not just renaming) invalidates the cache. Env map keys are sorted so ordering doesn't perturb the hash — Go map iteration order is randomised, so we'd otherwise get fingerprint churn on every launch.
func StartAvailableInto ¶
StartAvailableInto handshakes specs concurrently and adds each into the given (externally-owned) host, returning the union of their tools. It mirrors StartAvailable but does NOT allocate a host — the caller owns the host's lifecycle (desktop shares one host per workspace root across tabs). A failed plugin is recorded on the host via RecordFailure and skipped, matching StartAvailable's non-aborting behavior so one bad server can't block boot.
func ToolPrefix ¶
ToolPrefix is the model-visible namespace prefix for every tool from server.
Types ¶
type CachedSchema ¶
type CachedSchema struct {
Version int `json:"version"`
SpecHash string `json:"spec_hash"`
Capabilities map[string]bool `json:"capabilities"`
Tools []CachedTool `json:"tools"`
LastValidated time.Time `json:"last_validated"`
}
CachedSchema is the persisted snapshot of one server's handshake result. SpecHash gates reuse — Capabilities/Tools are only trusted when the caller's expectedHash (from SpecFingerprint of the current Spec) matches, so renaming env vars or swapping a command never serves stale tools.
func LoadCachedSchema ¶
func LoadCachedSchema(name, expectedHash string) (*CachedSchema, bool)
LoadCachedSchema returns the cached schema for name iff it exists, parses, and matches expectedHash. Any error → (nil, false): cache is best-effort, a corrupt file just means we re-handshake. Returning an error here would only invite callers to log it on every launch — silence is intentional.
type CachedTool ¶
type CachedTool struct {
Name string `json:"name"`
Description string `json:"description"`
Schema json.RawMessage `json:"schema"`
ReadOnly bool `json:"read_only"`
}
CachedTool mirrors the subset of an MCP tool definition we need to register a placeholder before the real handshake completes: Name (raw, server-local), Description (model-visible), Schema (raw JSON for input validation), ReadOnly (drives confirmation prompts).
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is one MCP server connection: a name plus the transport carrying its JSON-RPC. The MCP-level methods (initialize, listTools, …) are transport- agnostic — they go through t.
type Host ¶
type Host struct {
// contains filtered or unexported fields
}
Host owns the running plugin connections and closes them together. It also aggregates the prompts and resources discovered across servers, which the chat UI surfaces (prompts as slash commands, resources as @-references).
func NewHost ¶
func NewHost() *Host
NewHost returns an empty Host. Boot always constructs one — even with no plugins configured — so servers can be hot-added later via Add (the `/mcp add` command), which keeps the controller's host pointer stable for the session.
func Start ¶
Start is the unified batch-startup primitive behind StartAll / StartAvailable. It fans out handshakes in parallel under the policy's concurrency cap, gives each plugin its own per-plugin timeout, and either aborts the batch on first failure (AbortOnError=true) or records failures on the host and keeps going.
Result ordering matches specs (stable for /mcp status). For stdio plugins the subprocess is bound to the parent ctx, not the per-plugin startup timeout: successful servers stay alive after startup, while failed/time-limited starts are closed explicitly before the goroutine returns.
func StartAll ¶
StartAll connects every plugin in parallel, performs the MCP handshake, and returns the union of their tools (namespaced "mcp__<server>__<tool>"). On any failure it tears down everything started so far. The caller must Close the Host.
For stdio plugins, subprocess lifetime is bound to ctx (via exec.CommandContext): cancelling ctx kills the children and unblocks reads.
func StartAvailable ¶
StartAvailable connects every plugin it can and records failures on the host instead of aborting the whole session. The returned tools are the union of the successfully connected servers.
func (*Host) Add ¶
Add connects one server live: it performs the MCP handshake, discovers the server's tools (and prompts/resources when advertised), appends it to the host, and returns its namespaced tools for the caller to register. ctx bounds a stdio child's lifetime, so pass the session-scoped context — not a per-turn one — or the subprocess dies when that turn ends. Errors if the name is taken.
func (*Host) ClearFailure ¶
ClearFailure drops a recorded startup/connection failure for status UIs.
func (*Host) ReadResource ¶
ReadResource reads a resource uri from the named server. It is how the chat UI resolves an @server:uri reference — the uri need not be one listed by resources/list (servers may expose templated uris), so we read it directly.
func (*Host) RecordFailure ¶
RecordFailure stores a failed MCP connection attempt for status UIs.
func (*Host) Remove ¶
Remove disconnects the named server and drops its prompts/resources, returning the namespaced tool-name prefix ("mcp__<server>__") the caller unregisters from the tool registry, and whether the server was connected.
func (*Host) ServerNames ¶
ServerNames returns the connected servers' names, in connection order.
func (*Host) Servers ¶
func (h *Host) Servers() []ServerStatus
Servers returns a status summary per connected server, in connection order.
func (*Host) StartPhaseB ¶
StartPhaseB asynchronously fetches the auxiliary surfaces (prompts and resources) for every connected client. Boot calls it right after Start returns, on a session-scoped ctx, so the agent becomes responsive as soon as tools are ready and the slower list calls stream in afterwards. Each finished surface fires an MCPSurfaceReady event on sink so UIs (e.g. /mcp status) can refresh without polling. A nil sink is tolerated — the merge still happens. Errors are logged and swallowed: prompts/resources are non-essential and must not break the session over one slow server.
type Prompt ¶
type Prompt struct {
Name string // "mcp__<server>__<prompt>" — the slash-command body
Server string // owning server name
Raw string // original prompt name for prompts/get
Description string // human-readable summary
Args []PromptArg // declared arguments, in order
// contains filtered or unexported fields
}
Prompt is an MCP prompt exposed by a server. It surfaces in the chat TUI as a slash command "/mcp__<server>__<prompt>"; running it fetches the rendered prompt and sends it to the model as a turn.
type PromptArg ¶
type PromptArg struct {
Name string `json:"name"`
Description string `json:"description"`
Required bool `json:"required"`
}
PromptArg is one declared prompt argument. fairpeer maps space-separated positional command arguments onto these in order, matching Claude Code.
type Recommendation ¶
Recommendation is the result of inspecting a plugin's recent startup history. Demote is the actionable bit boot.go consumes (true → switch this plugin's tier to "lazy" for this session). P99 and Reason are descriptive only — Reason is meant to be surfaced to the user as a Notice so a sudden demotion isn't silent.
func Recommend ¶
func Recommend(name string, budget time.Duration, demoteAfter int) Recommendation
Recommend inspects the recent samples for name and decides whether the plugin should be demoted to "lazy" this session. The rule is simple: demote when the last demoteAfter samples all hit or exceed the blocking startup budget. Missing/empty stats → no demote (a fresh plugin gets the benefit of the doubt and one normal startup attempt).
budget == 0 disables the check (returns no-demote). demoteAfter <= 0 falls back to defaultDemoteAfter so callers can pass the config value verbatim without sanitising it.
type Resource ¶
type Resource struct {
Server string // owning server name
URI string // canonical resource uri
Name string // human-readable label
Description string
MimeType string
}
Resource is an MCP resource a server exposes. In chat it is referenced as "@<server>:<uri>" (e.g. "@docs:file://README.md"); the referenced content is fetched and prepended to the message sent to the model.
type ServerStatus ¶
type ServerStatus struct {
Name string
Transport string
Tools int
Prompts int
Resources int
ToolList []ToolInfo
}
ServerStatus summarises one connected server for the /mcp command.
type SessionExpiredError ¶
SessionExpiredError signals that the MCP server rejected the request because the session has expired (e.g. HTTP 404 "Session not found"). The Client layer catches this, re-initializes the MCP handshake, and retries the original call.
func (*SessionExpiredError) Error ¶
func (e *SessionExpiredError) Error() string
func (*SessionExpiredError) Unwrap ¶
func (e *SessionExpiredError) Unwrap() error
type Spec ¶
type Spec struct {
Name string
Type string
Command string
Args []string
Env map[string]string
URL string
Headers map[string]string
// Dir, when set, is the working directory of a stdio subprocess. Empty means
// inherit fairpeer's cwd (the default for user-configured plugins). It exists
// for cwd-aware servers like CodeGraph, which detect the project from the
// directory they are launched in — they must be pinned to the project root.
Dir string
// Stderr optionally mirrors plugin subprocess stderr output. Stderr is always
// captured in a bounded buffer for failure diagnostics; nil keeps it out of
// the terminal so child logs cannot corrupt interactive UIs.
Stderr io.Writer
// ReadOnlyToolNames marks trusted raw MCP tool names as read-only even when
// the server omits annotations.readOnlyHint. It is for first-party adapters
// with known semantics; user-configured plugins should rely on MCP metadata.
ReadOnlyToolNames map[string]bool
// ExposeToolNames, when non-empty, is a whitelist of model-visible tool names
// (post-namespacing, e.g. "mcp__codegraph__context") that the main loop's
// schema advertises; every other tool from this server is hidden (still
// callable by name via run_skill/subagents, but kept out of the system prompt
// to save tokens). Empty (the default) exposes all tools — the legacy
// behavior. Used by first-party adapters like CodeGraph to surface a compact
// 2–3 tool surface while keeping the rest reachable on demand.
ExposeToolNames map[string]bool
// StripRawPrefix, when non-empty, removes this prefix from each MCP tool's
// raw name before namespacing. For example, StripRawPrefix="codegraph_" turns
// "codegraph_context" into "context", yielding "mcp__codegraph__context"
// instead of the redundant "mcp__codegraph__codegraph_context". The original
// raw name is preserved for MCP protocol calls.
StripRawPrefix string
// LowPriority runs a stdio subprocess below normal scheduling priority, for
// background indexers (CodeGraph) that must not starve the user's machine.
LowPriority bool
// CallTimeout caps how long a single MCP JSON-RPC call may take when the
// caller's context has no deadline. Zero uses the default (60s). Prevents a
// slow or hung MCP server from blocking the agent indefinitely.
CallTimeout time.Duration
// ServerRisk is the config-declared risk class for ALL tools on this server
// (SPEC v2 §3.2A). Raw string ("read"/"write_local"/"exec"/"external"); the
// boot layer translates it into per-tool RiskOverrides on the permission Gate.
// Empty = "external" (safe default: MCP tools prompt). Kept as a string here
// so this package need not depend on internal/permission.
ServerRisk string
}
Spec declares an external MCP server. Type selects the transport: "stdio" (default) runs Command/Args/Env as a subprocess; "http" / "streamable-http" and "sse" connect to URL with optional static Headers.
type StartPolicy ¶
type StartPolicy struct {
// PerPluginTimeout caps how long a single plugin's handshake (start +
// initialize + listTools + listPrompts/Resources) may take. Zero disables.
// Exceeded plugins are recorded as failures and, when AbortOnError is set,
// tear down the whole batch with the timeout as the cause.
PerPluginTimeout time.Duration
// Concurrency caps how many handshakes run at once. Zero or negative means
// no cap (every plugin gets a goroutine immediately). A small cap prevents
// process storms / FD exhaustion when many MCP servers are configured.
Concurrency int
// AbortOnError makes any single failure tear down the partial batch and
// return an error (StartAll semantics). When false, failures are recorded
// on the host and other plugins keep going (StartAvailable semantics).
AbortOnError bool
}
StartPolicy tunes batch plugin startup. The zero value disables every safeguard, so most call sites should use the StartAll / StartAvailable wrappers, which fill in production defaults.
type StartupStats ¶
type StartupStats struct {
Version int `json:"version"`
SamplesMs []int64 `json:"samples_ms"`
LastSeen time.Time `json:"last_seen"`
}
StartupStats is the on-disk record of recent startup durations for one plugin. SamplesMs is oldest→newest (newest appended at the tail); LastSeen is the wall-clock time the most recent sample was recorded so a future "stale data" pruning step can act on it without re-parsing each sample.