plugin

package
v0.1.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 9, 2026 License: MIT Imports: 26 Imported by: 0

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

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultStartupBudget

func DefaultStartupBudget() time.Duration

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

func RecordStartup(name string, dur time.Duration) error

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

func SpecFingerprint(s Spec) string

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

func StartAvailableInto(ctx context.Context, h *Host, specs []Spec) []tool.Tool

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

func ToolPrefix(server string) string

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 Failure

type Failure struct {
	Name      string
	Transport string
	Error     string
}

Failure records one MCP server that was configured but could not connect.

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

func Start(ctx context.Context, specs []Spec, p StartPolicy) (*Host, []tool.Tool, error)

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

func StartAll(ctx context.Context, specs []Spec) (*Host, []tool.Tool, error)

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

func StartAvailable(ctx context.Context, specs []Spec) (*Host, []tool.Tool)

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

func (h *Host) Add(ctx context.Context, s Spec) ([]tool.Tool, error)

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

func (h *Host) ClearFailure(name string)

ClearFailure drops a recorded startup/connection failure for status UIs.

func (*Host) Close

func (h *Host) Close()

Close terminates all plugin connections.

func (*Host) Failures

func (h *Host) Failures() []Failure

Failures returns configured MCP servers that failed to connect.

func (*Host) Prompts

func (h *Host) Prompts() []Prompt

Prompts returns every MCP prompt discovered across connected servers.

func (*Host) ReadResource

func (h *Host) ReadResource(ctx context.Context, server, uri string) (string, error)

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

func (h *Host) RecordFailure(s Spec, err error)

RecordFailure stores a failed MCP connection attempt for status UIs.

func (*Host) Remove

func (h *Host) Remove(name string) (toolPrefix string, found bool)

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) Resources

func (h *Host) Resources() []Resource

Resources returns every MCP resource discovered across connected servers.

func (*Host) ServerNames

func (h *Host) ServerNames() []string

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

func (h *Host) StartPhaseB(ctx context.Context, sink event.Sink)

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.

func (Prompt) Get

func (p Prompt) Get(ctx context.Context, args map[string]string) (string, error)

Get fetches the prompt with the given arguments and flattens its returned messages into a single text block to send to the model.

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

type Recommendation struct {
	Demote bool
	P99    time.Duration
	Reason string
}

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

type SessionExpiredError struct {
	Server string
	Err    error
}

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.

type ToolInfo

type ToolInfo struct {
	Name        string
	Description string
}

ToolInfo is the human-facing metadata returned by MCP tools/list for one tool.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL