421 lines
15 KiB
Go
421 lines
15 KiB
Go
// MCP placeholder tools. Background startup registers cheap placeholder entries
|
|
// in the tool registry at boot — using the on-disk schema cache when it exists —
|
|
// and kicks the real subprocess spawn / handshake immediately. By the time the
|
|
// model calls a tool, the connection is usually already up.
|
|
//
|
|
// Cache-hit placeholders are PINNED for the whole session: they present the
|
|
// cached names/descriptions/schemas from boot onward and forward Execute to the
|
|
// real tools once the handshake completes, but the registry entries themselves
|
|
// are never replaced. The provider request's tools array is part of the cached
|
|
// prompt prefix, so swapping in live tools mid-session — whenever the live
|
|
// handshake differed from the cache — invalidated the whole conversation's
|
|
// provider cache at 10x miss pricing. Live drift lands in the schema cache and
|
|
// surfaces next session. Only the cache-miss connect stub still swaps (there
|
|
// was nothing real to present), a one-time cost per server.
|
|
package plugin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// 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 DefaultStartupBudget() time.Duration { return defaultStartTimeout }
|
|
|
|
// spawnState is the lazy-spawn state machine. Transitions are:
|
|
//
|
|
// idle → inFlight → ready
|
|
// idle → inFlight → failed
|
|
//
|
|
// All transitions are gated by lazySpawn.mu so only one goroutine runs the
|
|
// handshake even when multiple Execute calls race on first use.
|
|
type spawnState int
|
|
|
|
const (
|
|
spawnIdle spawnState = iota
|
|
spawnInFlight
|
|
spawnReady
|
|
spawnFailed
|
|
)
|
|
|
|
// lazySpawn is shared by every placeholder lazyTool registered for one
|
|
// server: they all observe the same state machine and trigger at most one
|
|
// handshake.
|
|
type lazySpawn struct {
|
|
spec Spec
|
|
host *Host
|
|
reg *tool.Registry
|
|
ctx context.Context // session-scoped — outlives any single turn
|
|
|
|
mu sync.Mutex
|
|
state spawnState
|
|
real map[string]tool.Tool // namespaced name → real tool, populated on success
|
|
spawnErr error
|
|
swapped bool
|
|
// removePrefix is set for cache-miss placeholders so trySwap drops the
|
|
// single "<server>__connect" stub before re-registering the real tools
|
|
// under their actual namespaced names. Cache-hit placeholders use the
|
|
// same names as the real tools, so reg.Add overwrites in place and no
|
|
// prefix removal is needed.
|
|
removePrefix string
|
|
}
|
|
|
|
// kick starts the spawn if it has not yet started. Background registration calls
|
|
// this immediately; tests may leave it idle to exercise the placeholder path.
|
|
func (s *lazySpawn) kick() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.state != spawnIdle {
|
|
return
|
|
}
|
|
if !s.host.beginDeferredSpawn() {
|
|
s.state = spawnFailed
|
|
s.spawnErr = fmt.Errorf("plugin host is closed")
|
|
return
|
|
}
|
|
s.state = spawnInFlight
|
|
go func() {
|
|
defer s.host.endDeferredSpawn()
|
|
s.run()
|
|
}()
|
|
}
|
|
|
|
// run does the handshake without holding mu (host.Add can take seconds), then
|
|
// reacquires mu to publish the result.
|
|
func (s *lazySpawn) run() {
|
|
real, err := s.host.Add(s.ctx, s.spec)
|
|
var cacheTools []tool.Tool
|
|
s.mu.Lock()
|
|
if err != nil {
|
|
if errors.Is(err, ErrSpawningInFlight) {
|
|
// Another tab is already spawning this server; reset to idle so
|
|
// the next call retries instead of recording a spurious failure.
|
|
s.state = spawnIdle
|
|
s.spawnErr = nil
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
if IsServerAlreadyConnected(err) {
|
|
// The server was already started by another controller sharing
|
|
// the same host. Fetch the tools from the existing client
|
|
// instead of entering the failed state.
|
|
if tools, err2 := s.host.ToolsFor(s.ctx, s.spec.Name); err2 == nil {
|
|
s.real = make(map[string]tool.Tool, len(tools))
|
|
for _, t := range tools {
|
|
s.real[t.Name()] = t
|
|
}
|
|
s.state = spawnReady
|
|
s.trySwap()
|
|
cacheTools = tools
|
|
s.mu.Unlock()
|
|
saveLazyCachedSchema(s.spec, cacheTools)
|
|
return
|
|
}
|
|
// ToolsFor failed — still not a real failure; just mark failed
|
|
// without recording it so /mcp status stays clean.
|
|
s.state = spawnFailed
|
|
s.spawnErr = err
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.state = spawnFailed
|
|
s.spawnErr = err
|
|
s.host.RecordFailure(s.spec, err)
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.real = make(map[string]tool.Tool, len(real))
|
|
for _, t := range real {
|
|
s.real[t.Name()] = t
|
|
}
|
|
s.state = spawnReady
|
|
s.trySwap()
|
|
cacheTools = real
|
|
s.mu.Unlock()
|
|
saveLazyCachedSchema(s.spec, cacheTools)
|
|
}
|
|
|
|
func saveLazyCachedSchema(spec Spec, real []tool.Tool) {
|
|
_ = SaveCachedSchema(spec.Name, CachedSchema{
|
|
SpecHash: SpecFingerprint(spec),
|
|
Capabilities: map[string]bool{"tools": len(real) > 0},
|
|
Tools: cacheableToolsOf(real),
|
|
})
|
|
}
|
|
|
|
// trySwap publishes the real tools after a successful spawn. Caller must hold
|
|
// s.mu.
|
|
//
|
|
// Cache-miss placeholders (removePrefix set) genuinely swap: the single
|
|
// "<server>__connect" stub is dropped and the real tools register under their
|
|
// own names — a one-time tool-set change per server, unavoidable because no
|
|
// schema existed to present earlier.
|
|
//
|
|
// Cache-hit placeholders do NOT touch the registry. The lazyTools already
|
|
// carry the cached names/descriptions/schemas the model has seen since boot,
|
|
// and Execute forwards to the real tool once ready — swapping in the live
|
|
// tools would rewrite the request's tools array mid-session whenever the live
|
|
// handshake differs from the cache (description tweaks, schema upgrades, new
|
|
// tools), invalidating the provider prefix cache at 10x miss pricing. The
|
|
// live result still lands in the schema cache (saveLazyCachedSchema), so the
|
|
// NEXT session presents the updated surface — freshness deferred one session
|
|
// in exchange for byte-stable tool bytes within this one, same trade the
|
|
// environment-probe snapshot makes for the system prompt.
|
|
func (s *lazySpawn) trySwap() {
|
|
if s.swapped || s.state != spawnReady {
|
|
return
|
|
}
|
|
if s.removePrefix != "" {
|
|
s.reg.RemovePrefix(s.removePrefix)
|
|
for _, t := range s.real {
|
|
s.reg.Add(t)
|
|
}
|
|
}
|
|
s.swapped = true
|
|
}
|
|
|
|
// lazyTool is a tool.Tool placeholder backed by a shared lazySpawn. The model
|
|
// sees cached metadata (or a stub when no cache exists); Execute consults the
|
|
// state machine, kicking off the handshake on first call.
|
|
type lazyTool struct {
|
|
shared *lazySpawn
|
|
name string // namespaced "mcp__<server>__<tool>"
|
|
rawName string // original server-local tool name, when cached
|
|
desc string
|
|
schema json.RawMessage
|
|
readOnly bool
|
|
// readOnlyTrusted mirrors remoteTool: true only for a first-party
|
|
// ReadOnlyToolNames override, so plan mode can tell trusted first-party
|
|
// read-only from an untrusted server readOnlyHint.
|
|
readOnlyTrusted bool
|
|
// hasCache true → schema is trusted, so Execute runs the handshake
|
|
// synchronously and forwards in one turn. false → schema is empty, so we
|
|
// can't honour the model's call; we kick the spawn async and ask for a
|
|
// retry on the next turn, when the swap will have installed the real
|
|
// tools with real schemas.
|
|
hasCache bool
|
|
}
|
|
|
|
func (lt *lazyTool) Name() string { return lt.name }
|
|
func (lt *lazyTool) Description() string { return lt.desc }
|
|
func (lt *lazyTool) ReadOnly() bool { return lt.readOnly }
|
|
func (lt *lazyTool) MCPServerName() string {
|
|
if lt.shared == nil {
|
|
return ""
|
|
}
|
|
return lt.shared.spec.Name
|
|
}
|
|
func (lt *lazyTool) MCPRawToolName() string { return lt.rawName }
|
|
|
|
// PlanModeUntrustedReadOnly mirrors remoteTool: true when ReadOnly() is true only
|
|
// from an untrusted server readOnlyHint, false for a first-party override.
|
|
func (lt *lazyTool) PlanModeUntrustedReadOnly() bool {
|
|
return lt.readOnly && !lt.readOnlyTrusted
|
|
}
|
|
func (lt *lazyTool) Schema() json.RawMessage {
|
|
if len(lt.schema) == 0 {
|
|
return json.RawMessage(`{"type":"object"}`)
|
|
}
|
|
return canonicalizeSchema(lt.schema)
|
|
}
|
|
|
|
func (lt *lazyTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
sp := lt.shared
|
|
sp.mu.Lock()
|
|
|
|
// Catch up on a background spawn that finished while we were idle.
|
|
if sp.state == spawnReady && !sp.swapped {
|
|
sp.trySwap()
|
|
}
|
|
|
|
switch sp.state {
|
|
case spawnReady:
|
|
real := sp.real[lt.name]
|
|
sp.mu.Unlock()
|
|
if real == nil {
|
|
return "", fmt.Errorf("MCP server %q did not expose tool %q (the cached schema may be stale)", sp.spec.Name, lt.name)
|
|
}
|
|
return real.Execute(ctx, args)
|
|
|
|
case spawnFailed:
|
|
err := sp.spawnErr
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q failed to start: %w", sp.spec.Name, err)
|
|
|
|
case spawnInFlight:
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q is still initializing — call this tool again on the next turn", sp.spec.Name)
|
|
|
|
case spawnIdle:
|
|
if !lt.hasCache {
|
|
// Cache-miss: we don't trust args to match a real schema, so
|
|
// drive the handshake async and ask the model to retry. By the
|
|
// next turn the swap will have installed the real tools with
|
|
// real schemas under different names.
|
|
if !sp.host.beginDeferredSpawn() {
|
|
sp.state = spawnFailed
|
|
sp.spawnErr = fmt.Errorf("plugin host is closed")
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q failed to start: %w", sp.spec.Name, sp.spawnErr)
|
|
}
|
|
sp.state = spawnInFlight
|
|
go func() {
|
|
defer sp.host.endDeferredSpawn()
|
|
sp.run()
|
|
}()
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q is initializing on first use — call again on the next turn for its real tools", sp.spec.Name)
|
|
}
|
|
// Cache-hit: run the handshake synchronously so this one Execute can
|
|
// forward through. Bound it with a start timeout so a wedged or
|
|
// unreachable MCP server can't hang the whole turn indefinitely
|
|
// (#4806) — on timeout we fail this attempt and a later turn can retry.
|
|
sp.state = spawnInFlight
|
|
sp.mu.Unlock()
|
|
spawnCtx, cancel := context.WithTimeout(sp.ctx, defaultStartTimeout)
|
|
real, err := sp.host.AddWithLifecycle(sp.ctx, spawnCtx, sp.spec)
|
|
cancel()
|
|
sp.mu.Lock()
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
// A slow cold start can succeed on a later turn after npm/node
|
|
// caches warm up or a remote MCP endpoint responds. Do not pin
|
|
// the session into spawnFailed for a transient startup budget miss.
|
|
sp.state = spawnIdle
|
|
sp.spawnErr = nil
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q startup timed out — retry this tool on a later turn", sp.spec.Name)
|
|
}
|
|
if errors.Is(err, ErrSpawningInFlight) {
|
|
// Another tab is already spawning this server on the shared
|
|
// host, but this lazySpawn has no goroutine that can publish
|
|
// that result. Reset to idle so the next call can reuse the
|
|
// connected client once the other spawn finishes.
|
|
sp.state = spawnIdle
|
|
sp.spawnErr = nil
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q is being started by another tab — retry on next turn", sp.spec.Name)
|
|
}
|
|
if IsServerAlreadyConnected(err) {
|
|
// Another tab on the shared host already started the
|
|
// server. Fetch the tools from the existing client.
|
|
if tools, err2 := sp.host.ToolsFor(ctx, sp.spec.Name); err2 == nil {
|
|
sp.real = make(map[string]tool.Tool, len(tools))
|
|
for _, t := range tools {
|
|
sp.real[t.Name()] = t
|
|
}
|
|
sp.state = spawnReady
|
|
sp.trySwap()
|
|
r := sp.real[lt.name]
|
|
if r != nil {
|
|
// Unlock before forwarding so the lock isn't held
|
|
// during Execute (matching the spawnReady pattern).
|
|
sp.mu.Unlock()
|
|
return r.Execute(ctx, args)
|
|
}
|
|
}
|
|
// ToolsFor failed — not our fault, don't record as failure.
|
|
sp.state = spawnFailed
|
|
sp.spawnErr = err
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q failed to start: %w", sp.spec.Name, err)
|
|
}
|
|
sp.state = spawnFailed
|
|
sp.spawnErr = err
|
|
sp.host.RecordFailure(sp.spec, err)
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q failed to start: %w", sp.spec.Name, err)
|
|
}
|
|
sp.real = make(map[string]tool.Tool, len(real))
|
|
for _, t := range real {
|
|
sp.real[t.Name()] = t
|
|
}
|
|
sp.state = spawnReady
|
|
sp.trySwap()
|
|
r := sp.real[lt.name]
|
|
if r == nil {
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("MCP server %q did not expose tool %q (the cached schema may be stale)", sp.spec.Name, lt.name)
|
|
}
|
|
sp.mu.Unlock()
|
|
return r.Execute(ctx, args)
|
|
}
|
|
|
|
sp.mu.Unlock()
|
|
return "", fmt.Errorf("deferred plugin %q in unexpected state", sp.spec.Name)
|
|
}
|
|
|
|
// LazyToolset returns the placeholder tools to register for one background spec.
|
|
// The name is historical: 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. If the background handshake is still pending, Execute waits for it
|
|
// 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 wait for
|
|
// 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 LazyToolset(spec Spec, cs *CachedSchema, host *Host, reg *tool.Registry, sessionCtx context.Context, kick bool) []tool.Tool {
|
|
spawnCtx, cancel := context.WithCancel(sessionCtx)
|
|
host.registerDeferredCancel(cancel)
|
|
shared := &lazySpawn{
|
|
spec: spec,
|
|
host: host,
|
|
reg: reg,
|
|
ctx: spawnCtx,
|
|
}
|
|
|
|
var out []tool.Tool
|
|
// A snapshot with zero tools presents nothing the model could call, so it
|
|
// gets the same connect stub as a cache miss — otherwise the live tools
|
|
// would silently join the registry mid-session with no placeholder names
|
|
// reserved for them.
|
|
if cs == nil || len(cs.Tools) == 0 {
|
|
shared.removePrefix = ToolPrefix(spec.Name)
|
|
out = []tool.Tool{&lazyTool{
|
|
shared: shared,
|
|
name: shared.removePrefix + "connect",
|
|
desc: fmt.Sprintf("Connect MCP server %q. Call this once to drive the handshake; the server's real tools become available on the next turn.", spec.Name),
|
|
hasCache: false,
|
|
}}
|
|
} else {
|
|
out = make([]tool.Tool, 0, len(cs.Tools))
|
|
for _, ct := range cs.Tools {
|
|
visibleName := ct.Name
|
|
if spec.StripRawPrefix != "" {
|
|
visibleName = strings.TrimPrefix(visibleName, spec.StripRawPrefix)
|
|
}
|
|
trusted := spec.toolReadOnlyTrusted(ct.Name, visibleName)
|
|
out = append(out, &lazyTool{
|
|
shared: shared,
|
|
name: toolName(spec.Name, visibleName),
|
|
rawName: ct.Name,
|
|
desc: ct.Description,
|
|
schema: ct.Schema,
|
|
readOnly: spec.toolReadOnly(ct.Name, visibleName, ct.ReadOnly),
|
|
readOnlyTrusted: trusted,
|
|
hasCache: true,
|
|
})
|
|
}
|
|
}
|
|
|
|
if kick {
|
|
shared.kick()
|
|
}
|
|
return out
|
|
}
|