// Package plugin is Reasonix'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 official MCP Go SDK owns protocol negotiation and // JSON-RPC sessions across stdio, Streamable HTTP, and legacy HTTP+SSE; Reasonix // retains product policy, lifecycle supervision, and transport security. package plugin import ( "context" "encoding/json" "errors" "fmt" "hash/fnv" "io" "log/slog" "maps" "net/http" "reflect" "regexp" "strings" "sync" "sync/atomic" "time" "reasonix/internal/event" "reasonix/internal/mcplaunch" "reasonix/internal/sandbox" "reasonix/internal/secrets" "reasonix/internal/tool" ) // MCPProcessMode selects how a local stdio MCP process is launched. // It is an internal runtime field, not a user-facing config knob. type MCPProcessMode string const ( // MCPProcessHost runs authorized stdio MCP as a trusted host process that // does not inherit the agent Bash command sandbox. This is the product // default so servers such as chrome-devtools-mcp can reach the real browser, // Keychain, LaunchServices, and local app services. MCPProcessHost MCPProcessMode = "host" // MCPProcessConfined wraps the process with sandbox.CommandArgs. Reserved for // internal managed deployments and tests; never auto-selected for user installs. MCPProcessConfined MCPProcessMode = "confined" ) // ResolvedProcessMode returns the effective process mode. Empty means host. func (s Spec) ResolvedProcessMode() MCPProcessMode { switch s.ProcessMode { case MCPProcessConfined: return MCPProcessConfined default: return MCPProcessHost } } // defaultCallTimeout is the MCP JSON-RPC call deadline applied when neither the // caller context nor config provides one. It is intentionally finite so a slow // or hung MCP server cannot block an agent turn indefinitely. const defaultCallTimeout = 300 * time.Second // 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 Spec struct { Name string // Package is the installed plugin package that contributed this server. // It is host-only provenance and intentionally excluded from fingerprints. Package string Type string Command string Args []string Env map[string]string URL string Headers map[string]string // DefaultStartupTimeout is the background initialize + tools/list safety cap // for this server. Zero keeps Reasonix's built-in default. DefaultStartupTimeout time.Duration // StartupTimeout overrides DefaultStartupTimeout for this server. It is // host-only lifecycle policy and never changes provider-visible tool schemas. StartupTimeout time.Duration // DefaultCallTimeout is the global MCP call cap for this server. Zero keeps // Reasonix's built-in defaultCallTimeout. DefaultCallTimeout time.Duration // CallTimeout overrides DefaultCallTimeout for all calls to this server. // Zero falls back to DefaultCallTimeout. CallTimeout time.Duration // ToolTimeouts overrides the per-call deadline for raw MCP tool names. // Keys are server-local tool names as returned by tools/list, not the // model-visible mcp__server__tool names. ToolTimeouts map[string]time.Duration // Dir, when set, is the working directory of a stdio subprocess. Empty means // inherit reasonix'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 // WorkspaceRoot is the project root exposed through the MCP roots capability. // It is runtime-only and intentionally separate from Dir: user-installed // stdio servers keep inheriting Reasonix's cwd while still receiving the // explicit workspace root when they ask for roots/list. WorkspaceRoot 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 // LaunchManager owns exact project launch grants and mutable launcher locks. // It never contributes to SchemaCacheKey or provider-visible tool schemas. LaunchManager *mcplaunch.Manager // ConfigSource disambiguates otherwise identical server names coming from // workspace config, a host transport, or a user-installed plugin package. ConfigSource string // Authorized is the single runtime authorization result for this server. // User-installed and explicit host-session servers set it directly; project // servers set it only after an exact launch grant is resolved. Authorized bool RequireLaunchApproval bool // LaunchArgs and launcher metadata are host-local immutable resolutions for // mutable package launchers. LauncherIdentityArgs is the same exact package // resolution without an automatically injected offline/no-install flag: that // enforcement-only flag changes process invocation but not the server identity // the user approved. These fields never contribute to SchemaCacheKey or the // provider-visible tool surface; Args remains the user's stable config. LaunchArgs []string LauncherIdentityArgs []string LauncherLocator string LauncherResolvedVersion string LauncherDigest string // ProcessMode selects host mode (default) or confined mode, which is reserved // for internal managed deployments and tests, never an automatic fallback. ProcessMode MCPProcessMode // Sandbox is only applied when ProcessMode is confined. Host-mode servers // keep private state/cache/temp dirs without wrapping the process in the // agent command sandbox. Sandbox sandbox.Spec StateDir string OAuthHTTPClient *http.Client // StripRawPrefix, when non-empty, removes this prefix from each MCP tool's // raw name before namespacing. For example, StripRawPrefix="server_" turns // "server_search" into "search", yielding "mcp__search__search" instead of // the redundant "mcp__search__server_search". The original raw name is // preserved for MCP protocol calls. StripRawPrefix string // LowPriority runs a stdio subprocess below normal scheduling priority, for // background indexers that must not starve the user's machine. LowPriority bool } // transport carries JSON-RPC messages to and from one MCP server. call sends a // request and returns its result; close releases resources. Transports route MCP // progress notifications to the active tool call and answer the client // capabilities Reasonix advertises (currently ping and roots/list). type transport interface { call(ctx context.Context, method string, params any) (json.RawMessage, error) close() } // 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). type Host struct { // mu guards the slices below: StartAll builds the Host single-threaded, but // after that a /mcp hot-add or -remove (one goroutine) can run concurrently // with reads from a running turn's @ref resolution or the status UI. mu sync.RWMutex clients []*Client prompts []Prompt resources []Resource failures []Failure closed bool // nextInstanceID assigns stable IDs to Client values appended to this Host. // nextScopeID assigns IDs to per-build RegistrationScope tokens. nextInstanceID atomic.Uint64 nextScopeID atomic.Uint64 // Lazy/background servers may still be handshaking when a session closes. // Close cancels those startup contexts and waits for their goroutines before // taking the client snapshot, so a just-connected stdio child cannot escape // teardown and keep a Windows workspace directory locked. deferredCancels map[string][]context.CancelFunc deferredGenerations map[string]uint64 deferredWG sync.WaitGroup // spawningMu + spawning prevent concurrent spawns of the same server from // multiple callers (e.g. several controller tabs sharing one Host). The // owner publishes its result before closing done so waiters can reuse the // discovered tools without issuing concurrent tools/list calls. spawningMu sync.Mutex spawning map[string]*spawnAttempt // proxies holds stable per-server backends for rolling replacement without // changing provider-visible tool prefixes (spatiotemporal composability). proxies map[string]*serverProxy // profile is the host's semantic client-capability surface, fixed at // creation; cache identity and the capability matrix derive from it. profile HostProfile // appInstances is the bounded MCP Apps instance registry, built with the // Host and never nil. appInstances *appInstanceRegistry // Detached stats/schema-cache writers from Start; off the boot path but // drained by Close so cleanup can't race a still-open cache file. bgWrites sync.WaitGroup surfaceWG sync.WaitGroup toolListChanges toolListSubscriptions } // 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 (h *Host) ReadResource(ctx context.Context, server, uri string) (string, error) { h.mu.RLock() var target *Client for _, c := range h.clients { if c.name == server { target = c break } } h.mu.RUnlock() if target == nil { return "", fmt.Errorf("no MCP server named %q", server) } return target.readResource(ctx, uri) // network call: outside the lock } // 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 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 // SkipPersistence disables RecordStartup / SaveCachedSchema side effects. // Use for read-only live probes (capability diagnostics) that must not // write MCP stats or schema cache files under Reasonix home. SkipPersistence bool } // defaultStartConcurrency caps parallel handshakes for the batch-start wrappers. // Eight is the standard "process storm" guardrail (Bazel's --jobs=auto, most LSP // managers) — large enough to mask single-plugin latency, small enough to spare // a workstation with 20+ configured MCP servers from fork-bombing itself. const defaultStartConcurrency = 8 // defaultStartTimeout is the per-plugin budget used by StartAvailable. Five // seconds covers a healthy stdio MCP spawning under a slow npm/node loader; past // that, an interactive user is better served by recording the failure and moving // on than by stalling the whole session. const defaultStartTimeout = 5 * time.Second var advertisedToolsEmptyListRetryDelays = []time.Duration{ 50 * time.Millisecond, 150 * time.Millisecond, 300 * time.Millisecond, } // ErrServerAlreadyConnected marks an attempted MCP connection whose server name // is already live on the host. var ErrServerAlreadyConnected = errors.New("plugin server already connected") func serverAlreadyConnectedError(name string) error { return fmt.Errorf("%w: %q", ErrServerAlreadyConnected, name) } // IsServerAlreadyConnected reports whether err means the MCP server name is // already live on the host. func IsServerAlreadyConnected(err error) bool { return errors.Is(err, ErrServerAlreadyConnected) } // StartAll connects every plugin in parallel, performs the MCP handshake, and // returns the union of their tools (namespaced "mcp____"). 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 StartAll(ctx context.Context, specs []Spec) (*Host, []tool.Tool, error) { return Start(ctx, specs, StartPolicy{ Concurrency: defaultStartConcurrency, AbortOnError: true, }) } // 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 StartAvailable(ctx context.Context, specs []Spec) (*Host, []tool.Tool) { h, tools, _ := Start(ctx, specs, StartPolicy{ PerPluginTimeout: defaultStartTimeout, Concurrency: defaultStartConcurrency, // AbortOnError stays false: a misconfigured plugin must not bring down // the whole session at boot. }) return h, tools } // 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 Start(ctx context.Context, specs []Spec, p StartPolicy) (*Host, []tool.Tool, error) { if len(specs) == 0 { return &Host{}, nil, nil } type result struct { idx int spec Spec client *Client tools []tool.Tool err error } // A buffered channel acts as a counting semaphore. Capacity 0/negative // means no cap — we still launch one goroutine per spec, but they all run // immediately. Capped, the extra goroutines block on the semaphore until a // slot frees up; collection order is still by idx so /mcp status is stable. concurrency := p.Concurrency if concurrency <= 0 || concurrency > len(specs) { concurrency = len(specs) } sem := make(chan struct{}, concurrency) ch := make(chan result, len(specs)) // Created before the fan-out so the detached cache writers can join bgWrites. h := &Host{} for i, s := range specs { go func(idx int, spec Spec) { sem <- struct{}{} defer func() { <-sem }() callCtx := ctx cancelStartup := func() {} if p.PerPluginTimeout > 0 { var cancel context.CancelFunc callCtx, cancel = context.WithTimeout(ctx, p.PerPluginTimeout) cancelStartup = cancel } phaseAStart := time.Now() recordedPhaseADur := func() time.Duration { dur := time.Since(phaseAStart) if p.PerPluginTimeout > 0 && callCtx.Err() == context.DeadlineExceeded && dur < p.PerPluginTimeout { return p.PerPluginTimeout } return dur } // Transport on the parent ctx, startup RPCs on the timed callCtx: the // per-plugin timeout caps initialize+listTools, but the long-lived // stdio child must outlive the startup scope and later phase-B calls. c, err := start(ctx, callCtx, spec, h.profile) if err != nil { phaseADur := recordedPhaseADur() cancelStartup() if !p.SkipPersistence { h.bgWrites.Go(func() { ; _ = RecordStartup(spec.Name, phaseADur) }) } ch <- result{idx: idx, spec: spec, err: fmt.Errorf("start plugin %q: %w", spec.Name, err)} return } h.bindToolListChanges(c) ts, err := c.listTools(callCtx) if err != nil { phaseADur := recordedPhaseADur() cancelStartup() if !p.SkipPersistence { h.bgWrites.Go(func() { ; _ = RecordStartup(spec.Name, phaseADur) }) } c.close() err = newStartupFailure("tools/list", phaseAStart, c.startupStderr(), err) ch <- result{idx: idx, spec: spec, err: fmt.Errorf("list tools from %q: %w", spec.Name, err)} return } // Persist for next launch on the side: a slow stats/cache write // must not delay tools coming online, and either failure is // recoverable (we just re-handshake or skip auto-demote). phaseADur := recordedPhaseADur() cancelStartup() if !p.SkipPersistence { h.bgWrites.Go(func() { _ = RecordStartup(spec.Name, phaseADur) _ = SaveCachedSchemaForProfile(h.profile, spec.Name, CachedSchema{ CacheKey: SchemaCacheKey(spec), Capabilities: map[string]bool{ "tools": c.capabilities.tools, "prompts": c.capabilities.prompts, "resources": c.capabilities.resources, }, Tools: cacheableToolsOf(ts), }) }) } // Prompts and resources are deferred to StartPhaseB so the boot path // can return as soon as tools are ready — the slow-to-list surfaces // stream in later and fan out an MCPSurfaceReady event each. ch <- result{idx: idx, spec: spec, client: c, tools: ts} }(i, s) } // Wait for every goroutine even on abort: started clients sit beyond a // failing index, so we need them all back to tear them down in Close(). results := make([]result, len(specs)) for range specs { r := <-ch results[r.idx] = r } var tools []tool.Tool var firstErr error for _, r := range results { if r.err != nil { if p.AbortOnError { if firstErr == nil { firstErr = r.err } } else { h.RecordFailure(r.spec, r.err) } continue } current, err := h.registerStartedClient(r.client, r.tools) if err != nil { r.client.close() if firstErr == nil { firstErr = err } continue } tools = append(tools, current...) // prompts/resources are filled in later by StartPhaseB. } if firstErr != nil { h.Close() return nil, nil, firstErr } return h, tools, nil } // Close terminates all plugin connections. func (h *Host) Close() { h.mu.Lock() if h.closed { h.mu.Unlock() return } h.closed = true var cancels []context.CancelFunc for _, serverCancels := range h.deferredCancels { cancels = append(cancels, serverCancels...) } h.deferredCancels = nil h.mu.Unlock() for _, cancel := range cancels { cancel() } h.deferredWG.Wait() h.mu.Lock() clients := append([]*Client(nil), h.clients...) proxies := h.proxies h.proxies = nil h.clients = nil h.toolListChanges.subscribers = nil h.mu.Unlock() closeServerProxies(proxies) for _, c := range clients { if c != nil && c.t != nil { c.close() } } h.surfaceWG.Wait() h.bgWrites.Wait() // drain detached stats/schema writers before returning } // queueBackgroundWrite keeps detached persistence inside the Host lifecycle. // Callers must enqueue before their Close-drained startup owner completes, so // Close cannot begin waiting before the WaitGroup increment is visible. func (h *Host) queueBackgroundWrite(write func()) { h.bgWrites.Go(func() { write() }) } func (h *Host) goSurface(work func()) bool { if h == nil || work == nil { return false } h.mu.Lock() if h.closed { h.mu.Unlock() return false } h.surfaceWG.Add(1) h.mu.Unlock() go func() { defer h.surfaceWG.Done() work() }() return true } // 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. func (h *Host) StartPhaseB(ctx context.Context, sink event.Sink) { h.mu.RLock() clients := append([]*Client(nil), h.clients...) h.mu.RUnlock() for _, c := range clients { if c.capabilities.prompts { h.goSurface(func() { h.fetchPrompts(ctx, c, sink) }) } if c.capabilities.resources { h.goSurface(func() { h.fetchResources(ctx, c, sink) }) } } } func (h *Host) fetchPrompts(ctx context.Context, c *Client, sink event.Sink) { auxCtx, cancel := context.WithTimeout(ctx, defaultStartTimeout) defer cancel() ps, err := c.listPrompts(auxCtx) if err != nil { if ctx.Err() == nil && !c.closed.Load() { slog.Warn("plugin: listPrompts failed", "server", c.name, "err", err) } return } for i := range ps { ps[i].client = c } h.mu.Lock() if h.closed || h.lookupClientLocked(c.name) != c { h.mu.Unlock() return } h.removeClientPromptsLocked(c) c.prompts = ps h.prompts = append(h.prompts, ps...) h.mu.Unlock() if sink != nil { sink.Emit(event.Event{ Kind: event.MCPSurfaceReady, Text: fmt.Sprintf("%s: prompts ready (%d items)", c.name, len(ps)), }) } } func (h *Host) fetchResources(ctx context.Context, c *Client, sink event.Sink) { auxCtx, cancel := context.WithTimeout(ctx, defaultStartTimeout) defer cancel() rs, err := c.listResources(auxCtx) if err != nil { if ctx.Err() == nil && !c.closed.Load() { slog.Warn("plugin: listResources failed", "server", c.name, "err", err) } return } h.mu.Lock() if h.closed || h.lookupClientLocked(c.name) != c { h.mu.Unlock() return } h.removeClientResourcesLocked(c) c.resources = rs h.resources = append(h.resources, rs...) h.mu.Unlock() if sink != nil { sink.Emit(event.Event{ Kind: event.MCPSurfaceReady, Text: fmt.Sprintf("%s: resources ready (%d items)", c.name, len(rs)), }) } } func (h *Host) removeClientPromptsLocked(c *Client) { kept := h.prompts[:0] for _, prompt := range h.prompts { if prompt.client != c { kept = append(kept, prompt) } } h.prompts = kept } func (h *Host) removeClientResourcesLocked(c *Client) { kept := h.resources[:0] for _, resource := range h.resources { if resource.Server != c.name { kept = append(kept, resource) } } h.resources = kept } // Client is one MCP server connection plus Reasonix's product-facing catalogs. // MCP operations are transport-agnostic and go through the supervised SDK session. type Client struct { name string instanceID uint64 // Host-local identity for RemoveIfInstance rollback t transport spec Spec profile HostProfile // registrationClaims and registrationCommitted are guarded by Host.mu. // Claims keep a tentative shared instance alive across overlapping builds; // the first published controller promotes it to ordinary Host ownership. registrationClaims map[uint64]struct{} registrationCommitted bool // Advertised surface and list-changed capabilities are kept together so // initialization publishes one coherent capability snapshot. capabilities clientCapabilities protocolVersion string transport string // declared transport type, for /mcp status ("stdio"/"http") // Prompts and resources discovered during StartAll, stored here so the // parallel startup can collect them per-client before merging into Host. prompts []Prompt resources []Resource // toolListFetchMu serializes tools/list requests. It may span the remote // request; toolsMu never does, so status readers cannot be stalled by MCP I/O. toolListFetchMu sync.Mutex toolsMu sync.RWMutex toolCatalog toolCatalogSnapshot // toolDispatchMu linearizes final adapter validation with tools/call and // catalog publication. A notification marks the catalog stale atomically, // so calls that have not entered this gate fail closed while it refreshes. toolDispatchMu sync.RWMutex catalogGeneration uint64 // guarded by toolDispatchMu closed atomic.Bool closeOnce sync.Once refresh toolListRefreshState surfaceStopsMu sync.Mutex surfaceStops []func() auxiliaryRefresh auxiliaryListRefreshState progressID atomic.Uint64 } // AuthorizeSpecLaunch records durable consent for an explicitly user-installed // project MCP without starting it a second time. The normal project discovery // path still requires a user action; install_source calls this only while // applying a plan the user already requested. Reuse an existing launcher lock // when one exists, but do not add a second network/version-resolution step to an // explicit install: the durable grant follows the exact configured command or // endpoint and future changes still invalidate it. func AuthorizeSpecLaunch(ctx context.Context, spec Spec) error { return authorizeSpecLaunch(ctx, spec, false) } // AuthorizeProjectSpecLaunch records the one durable launch confirmation used // for repository-discovered MCP configuration. Mutable package launchers are // resolved and locked, but the MCP server itself is not started: the caller can // connect it exactly once after this function returns. func AuthorizeProjectSpecLaunch(ctx context.Context, spec Spec) error { return authorizeSpecLaunch(ctx, spec, true) } func authorizeSpecLaunch(ctx context.Context, spec Spec, lockMutableLauncher bool) error { if !spec.RequireLaunchApproval { return nil } manager := spec.LaunchManager if manager == nil { return fmt.Errorf("MCP launch authorization store is unavailable") } var prepared Spec var launcherLock *mcplaunch.LauncherLock var err error if lockMutableLauncher { prepared, launcherLock, err = preparePersistentLauncher(ctx, spec) } else { prepared, err = applyStoredLauncherLock(spec) } if err != nil { return err } identityDigest, err := projectLaunchIdentityDigest(ctx, prepared) if err != nil { return err } if launcherLock != nil { // Store the resolution before the grant so a failed state write cannot // leave an authorization whose exact launcher identity is unavailable. if err := manager.PutLauncherLock(*launcherLock); err != nil { return err } } return manager.Authorize(prepared.Name, launchConfigSource(prepared), identityDigest) } // Failure records one MCP server that was configured but could not connect. type Failure struct { Name string Transport string Error string Stage string Elapsed time.Duration Stderr string RequiresLaunchApproval bool } type launchApprovalError struct { server string changed bool } func (e *launchApprovalError) Error() string { if e.changed { return fmt.Sprintf("project-provided MCP server %q changed; blocked before process or network startup and requires explicit re-authorization", e.server) } return fmt.Sprintf("project-provided MCP server %q is blocked before process or network startup until the user authorizes it", e.server) } func requiresLaunchApproval(err error) bool { var launchTarget *launchApprovalError return errors.As(err, &launchTarget) } // Servers returns a status summary per connected server, in connection order. func (h *Host) Servers() []ServerStatus { h.mu.RLock() defer h.mu.RUnlock() out := make([]ServerStatus, 0, len(h.clients)) for _, c := range h.clients { c.toolsMu.RLock() s := ServerStatus{ Name: c.name, Transport: c.transport, ConfigSource: strings.TrimSpace(c.spec.ConfigSource), Tools: len(c.toolCatalog.adapters), HasTools: c.capabilities.tools, } fillServerNegotiation(&s, h.profile, c) s.ToolList = append([]ToolInfo(nil), c.toolCatalog.infos...) adapters := append([]tool.Tool(nil), c.toolCatalog.adapters...) c.toolsMu.RUnlock() for _, adapter := range adapters { if binding, ok := tool.MCPBindingOf(adapter); ok { s.ToolBindings = append(s.ToolBindings, binding) } } if provider, ok := c.t.(sessionDiagnosticsProvider); ok { diagnostics := provider.sessionDiagnostics() s.ProtocolVersion = diagnostics.ProtocolVersion s.SessionState = diagnostics.State s.SessionIDPresent = diagnostics.SessionIDPresent s.ReconnectAttempts = diagnostics.ReconnectAttempts s.LastErrorKind = diagnostics.LastErrorKind s.LastError = diagnostics.LastError } for _, p := range h.prompts { if p.Server == c.name { s.Prompts++ } } for _, r := range h.resources { if r.Server == c.name { s.Resources++ } } out = append(out, s) } return out } // RecordFailure stores a failed MCP connection attempt for status UIs. func (h *Host) RecordFailure(s Spec, err error) { h.mu.Lock() defer h.mu.Unlock() tt := strings.ToLower(strings.TrimSpace(s.Type)) if tt == "" { tt = "stdio" } stage, elapsed, stderr := startupFailureDetails(err) f := Failure{ Name: s.Name, Transport: tt, Error: summarizeFailureError(err), Stage: stage, Elapsed: elapsed, Stderr: stderr, RequiresLaunchApproval: requiresLaunchApproval(err), } for i := range h.failures { if h.failures[i].Name == s.Name { h.failures[i] = f return } } h.failures = append(h.failures, f) } // RecordLaunchApprovalRequired keeps an intentionally disconnected project MCP // visible as awaiting authorization. This is used after an explicit launch // revocation, where no failed connection attempt exists to create the status. func (h *Host) RecordLaunchApprovalRequired(s Spec) { h.RecordFailure(s, &launchApprovalError{server: s.Name}) } // ClearFailure drops a recorded startup/connection failure for status UIs. func (h *Host) ClearFailure(name string) { h.mu.Lock() defer h.mu.Unlock() h.clearFailure(name) } // clearFailure drops the failure record for name. The caller holds h.mu (Lock) — // it runs inside addConnected / Remove, which already mutate under the lock. func (h *Host) clearFailure(name string) { kept := h.failures[:0] for _, f := range h.failures { if f.Name != name { kept = append(kept, f) } } h.failures = kept } // NewHost returns an empty core-v1 Host. Boot always constructs one — even // with no plugins configured — so servers can be hot-added later via Add (the // `/mcp add` command), keeping the controller's host pointer stable. func NewHost() *Host { return NewHostWithProfile(HostProfileCore) } // NewHostWithProfile returns an empty Host declaring the given profile's // client capabilities. Immutable once set; a degraded frontend constructs // the Host with a lower profile instead of mutating a live one. func NewHostWithProfile(profile HostProfile) *Host { return &Host{profile: profile.Normalize(), appInstances: newAppInstanceRegistry()} } // Profile returns the host's semantic capability profile, fixed at creation. func (h *Host) Profile() HostProfile { return h.profile.Normalize() } func (h *Host) registerDeferredCancel(name string, cancel context.CancelFunc) uint64 { h.mu.Lock() defer h.mu.Unlock() if h.closed { cancel() return 0 } if h.deferredCancels == nil { h.deferredCancels = make(map[string][]context.CancelFunc) } if h.deferredGenerations == nil { h.deferredGenerations = make(map[string]uint64) } generation := h.deferredGenerations[name] if generation == 0 { generation = 1 h.deferredGenerations[name] = generation } h.deferredCancels[name] = append(h.deferredCancels[name], cancel) return generation } func (h *Host) beginDeferredSpawn() bool { h.mu.Lock() defer h.mu.Unlock() if h.closed { return false } h.deferredWG.Add(1) return true } func (h *Host) endDeferredSpawn() { h.deferredWG.Done() } // ErrSpawningInFlight is returned by Host.Add when another caller is already // spawning the same server on this host. The caller should retry later. var ErrSpawningInFlight = errors.New("server spawn already in progress") type spawnAttempt struct { server string done chan struct{} tools []tool.Tool err error } // ConnectionResult is the eventual result of a session-owned background MCP // handshake. Tools are provider adapters and remain off the caller's registry // unless the caller explicitly registers them. type ConnectionResult struct { Tools []tool.Tool Err error } // EnsureConnectedInBackground starts or joins one shared initialize + // tools/list handshake owned by lifeCtx. The returned channel is buffered, so a // caller may stop waiting while the server continues toward readiness. Host // shutdown and Remove cancel the background work and wait for its goroutine. func (h *Host) EnsureConnectedInBackground(lifeCtx context.Context, s Spec) <-chan ConnectionResult { result := make(chan ConnectionResult, 1) startupBase, cancelStartupBase := context.WithCancel(lifeCtx) generation := h.registerDeferredCancel(s.Name, cancelStartupBase) if !h.beginDeferredSpawn() { cancelStartupBase() result <- ConnectionResult{Err: fmt.Errorf("plugin host is closed")} return result } go func() { defer h.endDeferredSpawn() defer cancelStartupBase() started := time.Now() startupCtx, cancelStartup := context.WithTimeout(startupBase, s.startupTimeout()) tools, err := h.EnsureConnectedWithLifecycle(lifeCtx, startupCtx, s, generation) cancelStartup() if err != nil { err = newStartupFailure("connect", started, "", err) if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrDeferredSpawnCancelled) { h.RecordFailure(s, err) } } result <- ConnectionResult{Tools: tools, Err: err} }() return result } // beginSpawn atomically claims the sole right to spawn the named server. // Returns owner=true if the caller should proceed. When another caller is // already spawning the same server, owner=false and done is closed when that // spawn finishes. func (h *Host) beginSpawn(key, server string) (*spawnAttempt, bool) { h.spawningMu.Lock() defer h.spawningMu.Unlock() if h.spawning == nil { h.spawning = make(map[string]*spawnAttempt) } if attempt, ok := h.spawning[key]; ok { return attempt, false } attempt := &spawnAttempt{server: server, done: make(chan struct{})} h.spawning[key] = attempt return attempt, true } // endSpawn releases the spawn claim for the named server. func (h *Host) endSpawn(name string, tools []tool.Tool, err error) { h.spawningMu.Lock() if attempt, ok := h.spawning[name]; ok { attempt.tools = append([]tool.Tool(nil), tools...) attempt.err = err delete(h.spawning, name) close(attempt.done) } h.spawningMu.Unlock() } // has reports whether a server with this name is already connected. func (h *Host) has(name string) bool { h.mu.RLock() defer h.mu.RUnlock() return h.hasLocked(name) } func (h *Host) hasLocked(name string) bool { for _, c := range h.clients { if c.name == name { return true } } return false } // HasClient reports whether a server with this name is already connected to the host. func (h *Host) HasClient(name string) bool { return h.has(name) } // HasClientForSpec reports whether the shared Host client for spec.Name was // created from the same runtime connection identity. Server names are only a // display/routing namespace; they are not sufficient authorization identity // when controllers with different project configs share one Host. func (h *Host) HasClientForSpec(spec Spec) bool { c := h.client(spec.Name) return c != nil && MCPRuntimeSpecMatches(c.spec, spec) } // ToolsFor returns the namespaced tool instances for an already-connected client. // ctx bounds the tools/list call so a non-responsive server does not hang // permanently. An error is returned when no client with that name is connected. func (h *Host) ToolsFor(ctx context.Context, name string) ([]tool.Tool, error) { h.mu.RLock() closed := h.closed h.mu.RUnlock() if closed { return nil, fmt.Errorf("plugin host is closed") } // Attempt to resolve via the existing Client. c := h.client(name) if c == nil { return nil, fmt.Errorf("client %q not found on shared host", name) } if err := h.claimClientFromContext(ctx, c); err != nil { return nil, err } if tools, ok := c.cachedTools(); ok { return tools, nil } return c.listTools(ctx) } // ToolsForSpec is the identity-bound variant used by stable capability // frontends. It refuses a same-name client from another controller, project // identity, endpoint, or prior hot-update generation instead of treating that // client as the current runtime's authorized server. func (h *Host) ToolsForSpec(ctx context.Context, spec Spec) ([]tool.Tool, error) { h.mu.RLock() closed := h.closed h.mu.RUnlock() if closed { return nil, fmt.Errorf("plugin host is closed") } c := h.client(spec.Name) if c == nil { return nil, fmt.Errorf("client %q not found on shared host", spec.Name) } if !MCPRuntimeSpecMatches(c.spec, spec) { return nil, fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration", spec.Name) } if err := h.claimClientFromContext(ctx, c); err != nil { return nil, err } if tools, ok := c.cachedTools(); ok { return tools, nil } return c.listTools(ctx) } // MCPRuntimeSpecMatches compares the complete host-local runtime behavior of // two specs while deliberately excluding non-behavioral handles such as the // stderr writer and LaunchManager pointer. Secret values are compared only in // memory and are never serialized into diagnostics or provider-visible state. func MCPRuntimeSpecMatches(a, b Spec) bool { return reflect.DeepEqual(mcpRuntimeSpecIdentityOf(a), mcpRuntimeSpecIdentityOf(b)) } // MCPToolMatchesSpec reports whether a concrete plugin adapter or pinned lazy // placeholder belongs to the requested runtime spec. Unknown tool // implementations fail closed when a runtime-bound capability frontend asks. func MCPToolMatchesSpec(t tool.Tool, spec Spec) bool { switch typed := t.(type) { case *remoteTool: return typed != nil && typed.client != nil && MCPRuntimeSpecMatches(typed.client.spec, spec) case *lazyTool: return typed != nil && typed.shared != nil && MCPRuntimeSpecMatches(typed.shared.spec, spec) default: return false } } type mcpRuntimeSpecIdentity struct { Name string Package string Type string Command string Args []string Env map[string]string URL string Headers map[string]string DefaultStartupTimeout time.Duration StartupTimeout time.Duration DefaultCallTimeout time.Duration CallTimeout time.Duration ToolTimeouts map[string]time.Duration Dir string WorkspaceRoot string LaunchWorkspace string ConfigSource string RequireLaunchApproval bool LaunchArgs []string LauncherIdentityArgs []string LauncherLocator string LauncherResolvedVersion string LauncherDigest string ProcessMode MCPProcessMode Sandbox sandbox.Spec StateDir string StripRawPrefix string LowPriority bool } func mcpRuntimeSpecIdentityOf(s Spec) mcpRuntimeSpecIdentity { launchWorkspace := "" if s.LaunchManager != nil { launchWorkspace = s.LaunchManager.WorkspaceFingerprint() } return mcpRuntimeSpecIdentity{ Name: strings.TrimSpace(s.Name), Package: strings.TrimSpace(s.Package), Type: canonicalMCPRuntimeTransport(s.Type), Command: s.Command, Args: nonEmptyStrings(s.Args), Env: nonEmptyStringMap(s.Env), URL: s.URL, Headers: nonEmptyStringMap(s.Headers), DefaultStartupTimeout: s.DefaultStartupTimeout, StartupTimeout: s.StartupTimeout, DefaultCallTimeout: s.DefaultCallTimeout, CallTimeout: s.CallTimeout, ToolTimeouts: nonEmptyDurationMap(s.ToolTimeouts), Dir: s.Dir, WorkspaceRoot: s.WorkspaceRoot, LaunchWorkspace: launchWorkspace, ConfigSource: strings.TrimSpace(s.ConfigSource), RequireLaunchApproval: s.RequireLaunchApproval, LaunchArgs: nonEmptyStrings(s.LaunchArgs), LauncherIdentityArgs: nonEmptyStrings(s.LauncherIdentityArgs), LauncherLocator: s.LauncherLocator, LauncherResolvedVersion: s.LauncherResolvedVersion, LauncherDigest: s.LauncherDigest, ProcessMode: s.ResolvedProcessMode(), Sandbox: canonicalMCPRuntimeSandbox(s.Sandbox), StateDir: s.StateDir, StripRawPrefix: s.StripRawPrefix, LowPriority: s.LowPriority, } } func canonicalMCPRuntimeTransport(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "", "stdio": return "stdio" case "http", "streamable-http", "streamable_http": return "streamable-http" case "sse": return "sse" default: return strings.ToLower(strings.TrimSpace(raw)) } } func canonicalMCPRuntimeSandbox(in sandbox.Spec) sandbox.Spec { in.WriteRoots = nonEmptyStrings(in.WriteRoots) in.ForbidReadRoots = nonEmptyStrings(in.ForbidReadRoots) return in } func nonEmptyStrings(in []string) []string { if len(in) == 0 { return nil } return in } func nonEmptyStringMap(in map[string]string) map[string]string { if len(in) == 0 { return nil } return in } func nonEmptyDurationMap(in map[string]time.Duration) map[string]time.Duration { if len(in) == 0 { return nil } return in } func (h *Host) client(name string) *Client { return h.lookupClient(name) } // 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 (h *Host) Add(ctx context.Context, s Spec) ([]tool.Tool, error) { return h.addWithLifecycle(ctx, ctx, s, 0) } // EnsureConnected returns tools for an already-connected server, or starts the // shared single-flight handshake and waits for it. Concurrent callers for the // same server share one initialize/tools-list; cancelling a waiter only cancels // that wait and never kills a process still used by other runtimes. func (h *Host) EnsureConnected(ctx context.Context, s Spec) ([]tool.Tool, error) { return h.EnsureConnectedWithLifecycle(ctx, ctx, s, 0) } // EnsureConnectedWithLifecycle is EnsureConnected with separate subprocess // lifetime (lifeCtx) and startup/call (callCtx) contexts, plus an optional // deferred generation for lazy registration. func (h *Host) EnsureConnectedWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) { if deferredGeneration != 0 && !h.deferredGenerationCurrent(s.Name, deferredGeneration) { return nil, ErrDeferredSpawnCancelled } if tools, err := h.ToolsFor(callCtx, s.Name); err == nil { return tools, nil } tools, err := h.addWithLifecycle(lifeCtx, callCtx, s, deferredGeneration) if IsServerAlreadyConnected(err) { return h.ToolsFor(callCtx, s.Name) } return tools, err } // AddWithLifecycle connects one server live, allowing caller to specify separate // contexts for the subprocess lifecycle (lifeCtx, session-scoped) and the startup // handshake/list calls (callCtx, turn-scoped/timeout-bound). func (h *Host) AddWithLifecycle(lifeCtx, callCtx context.Context, s Spec) ([]tool.Tool, error) { return h.addWithLifecycle(lifeCtx, callCtx, s, 0) } func (h *Host) addWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) { if deferredGeneration != 0 && !h.deferredGenerationCurrent(s.Name, deferredGeneration) { return nil, ErrDeferredSpawnCancelled } if h.has(s.Name) { return nil, serverAlreadyConnectedError(s.Name) } spawnKey := s.Name if deferredGeneration != 0 { spawnKey = fmt.Sprintf("%s#%d", s.Name, deferredGeneration) } attempt, owner := h.beginSpawn(spawnKey, s.Name) if !owner { select { case <-attempt.done: if attempt.err != nil { return nil, attempt.err } return append([]tool.Tool(nil), attempt.tools...), nil case <-callCtx.Done(): return nil, callCtx.Err() case <-lifeCtx.Done(): return nil, lifeCtx.Err() } } var tools []tool.Tool var err error defer func() { h.endSpawn(spawnKey, tools, err) }() // Double-check after acquiring the spawn token: another caller may have // connected the server between our h.has check and beginSpawn. if h.has(s.Name) { err = serverAlreadyConnectedError(s.Name) return nil, err } tools, err = h.addConnectedWithLifecycle(lifeCtx, callCtx, s, deferredGeneration) return tools, err } func (h *Host) addConnected(ctx context.Context, s Spec) ([]tool.Tool, error) { return h.addConnectedWithLifecycle(ctx, ctx, s, 0) } func (h *Host) addConnectedWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) { startupStarted := time.Now() h.mu.RLock() if h.closed { h.mu.RUnlock() return nil, fmt.Errorf("plugin host is closed") } h.mu.RUnlock() c, err := start(lifeCtx, callCtx, s, h.profile) if err != nil { return nil, err } h.bindToolListChanges(c) ts, err := c.listTools(callCtx) if err != nil { c.close() err = newStartupFailure("tools/list", startupStarted, c.startupStderr(), err) return nil, fmt.Errorf("list tools: %w", err) } h.mu.Lock() if h.closed { h.mu.Unlock() c.close() return nil, fmt.Errorf("plugin host is closed") } if deferredGeneration != 0 && h.deferredGenerations[s.Name] != deferredGeneration { h.mu.Unlock() c.close() return nil, ErrDeferredSpawnCancelled } if h.hasLocked(s.Name) { h.mu.Unlock() c.close() return nil, serverAlreadyConnectedError(s.Name) } // Attribute ownership from lifeCtx so LazyToolset background kicks and // boot.Build share the same RegistrationScope token. Sibling hot-adds // without a scope are never journaled to a concurrent build. if err := h.noteClientFromContext(lifeCtx, c); err != nil { h.mu.Unlock() c.close() return nil, err } h.clearFailure(s.Name) h.mu.Unlock() if cached, ok := c.cachedTools(); ok { ts = cached } // Prompts and resources stream in on the long lifeCtx the caller passed (Host.Add // uses the session-scoped PluginCtx, not a per-turn ctx), so the slow list // calls cannot starve a /mcp add of its return value. nil sink keeps hot-add // quiet — the chat UI re-queries Host.Prompts()/Resources() on demand. if c.capabilities.prompts { h.goSurface(func() { h.fetchPrompts(lifeCtx, c, nil) }) } if c.capabilities.resources { h.goSurface(func() { h.fetchResources(lifeCtx, c, nil) }) } return ts, nil } // Remove disconnects the named server and drops its prompts/resources, returning // the namespaced tool-name prefix ("mcp____") the caller unregisters from // the tool registry, and whether the server was connected. func (h *Host) Remove(name string) (toolPrefix string, found bool) { h.mu.Lock() cancels := append([]context.CancelFunc(nil), h.deferredCancels[name]...) delete(h.deferredCancels, name) if h.deferredGenerations == nil { h.deferredGenerations = make(map[string]uint64) } h.deferredGenerations[name]++ if h.deferredGenerations[name] == 0 { h.deferredGenerations[name] = 1 } idx := -1 for i, c := range h.clients { if c.name == name { idx = i break } } if idx < 0 { h.mu.Unlock() for _, cancel := range cancels { cancel() } if len(cancels) == 0 { return "", false } return ToolPrefix(name), true } removed := h.removeClientAtLocked(idx) if h.appInstances != nil { h.appInstances.ReleaseServer(name) } h.mu.Unlock() for _, cancel := range cancels { cancel() } removed.close() // kills the subprocess: outside the lock return "mcp__" + normalizeName(name) + "__", true } func (h *Host) deferredGenerationCurrent(name string, generation uint64) bool { h.mu.RLock() defer h.mu.RUnlock() return !h.closed && generation != 0 && h.deferredGenerations[name] == generation } // ErrDeferredSpawnCancelled marks a lazy generation invalidated by remove or // host shutdown before it could publish a client. var ErrDeferredSpawnCancelled = errors.New("deferred MCP spawn cancelled") // start opens the transport on lifeCtx (whose cancellation later closes the // subprocess) and uses callCtx for the initialize round-trip (whose cancellation // only bounds startup RPCs). Splitting the two lets a per-plugin timeout cap // handshake latency without making the timeout context own a successfully // registered stdio server; the child also has to outlive phase A so phase B // (prompts + resources) can still call it later. Callers that don't care pass // the same ctx for both. func start(lifeCtx, callCtx context.Context, s Spec, profile HostProfile) (*Client, error) { started := time.Now() var err error s, err = applyStoredLauncherLock(s) if err != nil { return nil, newStartupFailure("launch", started, "", err) } s, err = resolveProjectLaunchAuthorization(callCtx, s) if err != nil { return nil, newStartupFailure("authorization", started, "", err) } t, err := newTransport(lifeCtx, s, profile) if err != nil { return nil, newStartupFailure("launch", started, "", err) } tt := strings.ToLower(strings.TrimSpace(s.Type)) if tt == "" { tt = "stdio" } refreshCtx := lifeCtx if refreshCtx == nil { refreshCtx = context.Background() } refreshCtx, cancelRefresh := context.WithCancel(refreshCtx) c := &Client{ name: s.Name, t: t, spec: s, profile: profile.Normalize(), transport: tt, refresh: toolListRefreshState{ ctx: refreshCtx, cancel: cancelRefresh, }, } if err := c.initialize(callCtx); err != nil { c.close() err = newStartupFailure("initialize", started, c.startupStderr(), err) return nil, err } return c, nil } // resolveProjectLaunchAuthorization deliberately skips identity resolution for // installed and host-session servers. Their explicit installation is already // the authorization decision; only repository-declared servers need an exact // executable or endpoint digest before startup. func resolveProjectLaunchAuthorization(ctx context.Context, s Spec) (Spec, error) { if !s.RequireLaunchApproval { return s, nil } identityDigest, err := projectLaunchIdentityDigest(ctx, s) if err != nil { return s, err } return applyEstablishedLaunchGrant(s, identityDigest) } func applyEstablishedLaunchGrant(s Spec, identityDigest string) (Spec, error) { if !s.RequireLaunchApproval { return s, nil } if s.LaunchManager == nil { return s, fmt.Errorf("MCP launch authorization store is unavailable") } authorized, changed, err := s.LaunchManager.LaunchAuthorized(s.Name, launchConfigSource(s), identityDigest) if err != nil { return s, err } if !authorized { return s, &launchApprovalError{server: s.Name, changed: changed} } // A matching exact-identity launch grant is the user's authorization for // this project server. Calls proceed like an explicit install, while global // deny rules and execution safety boundaries remain authoritative. s.Authorized = true return s, nil } // ResolveStoredAuthorization applies an existing exact project grant without // starting a process or opening a network connection. Cached lazy/on-demand // tools use it before strict read-only filtering so every execution path sees // the same server-level authorization. Errors fail closed by returning the // original unauthorized Spec; a parent connection surfaces the detailed error. func ResolveStoredAuthorization(ctx context.Context, s Spec) Spec { if !s.RequireLaunchApproval { return s } locked, err := applyStoredLauncherLock(s) if err != nil { return s } authorized, err := resolveProjectLaunchAuthorization(ctx, locked) if err != nil { return s } return authorized } // ServerAuthorized is the single MCP authorization source. Tools do not carry // an independent trust bit: installation or an exact project launch grant // authorizes the server, while read-only/destructive classification remains a // live per-tool safety fact. func (s Spec) ServerAuthorized() bool { return s.Authorized } // newTransport builds the transport for a spec's declared type. Empty / unknown // defaults to stdio. func newTransport(ctx context.Context, s Spec, profile HostProfile) (transport, error) { return newSDKSessionTransport(ctx, s, profile) } func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) { params, unregisterProgress := c.withProgress(ctx, method, params) defer unregisterProgress() callCtx, cancel, timeout := c.contextWithCallTimeout(ctx, method, params) if cancel != nil { defer cancel() } started := time.Now() res, err := c.callTransport(callCtx, method, params) c.observeProtocol(method, res, time.Since(started), err) if timeout > 0 && errors.Is(err, context.DeadlineExceeded) && callCtx.Err() == context.DeadlineExceeded && ctx.Err() == nil { slog.Warn("plugin: MCP call timed out", "server", c.name, "method", method, "tool", rawToolNameFromCallParams(params), "timeout", timeout) return nil, c.timeoutError(method, params, timeout) } return res, err } func (c *Client) withProgress(ctx context.Context, method string, params any) (any, func()) { if method != "tools/call" { return params, func() {} } sink, ok := tool.ProgressFrom(ctx) if !ok { return params, func() {} } router, ok := c.t.(progressTransport) if !ok { return params, func() {} } callParams, ok := params.(map[string]any) if !ok { return params, func() {} } token := fmt.Sprintf("reasonix-%d", c.progressID.Add(1)) copyParams := make(map[string]any, len(callParams)) maps.Copy(copyParams, callParams) meta := map[string]any{} if existing, ok := callParams["_meta"].(map[string]any); ok { maps.Copy(meta, existing) } meta["progressToken"] = token copyParams["_meta"] = meta unregister := router.registerProgress(token, sink) return copyParams, unregister } func (c *Client) callTransport(ctx context.Context, method string, params any) (json.RawMessage, error) { return c.t.call(ctx, method, params) } func (c *Client) contextWithCallTimeout(ctx context.Context, method string, params any) (context.Context, context.CancelFunc, time.Duration) { if _, ok := ctx.Deadline(); ok { return ctx, nil, 0 } timeout := c.callTimeout(method, params) if timeout <= 0 { timeout = defaultCallTimeout } callCtx, cancel := context.WithTimeout(ctx, timeout) return callCtx, cancel, timeout } func (c *Client) callTimeout(method string, params any) time.Duration { if method == "tools/call" { if raw := rawToolNameFromCallParams(params); raw != "" { if timeout := c.spec.ToolTimeouts[raw]; timeout > 0 { return timeout } } } if c.spec.CallTimeout > 0 { return c.spec.CallTimeout } if c.spec.DefaultCallTimeout > 0 { return c.spec.DefaultCallTimeout } return defaultCallTimeout } func rawToolNameFromCallParams(params any) string { m, ok := params.(map[string]any) if !ok { return "" } name, _ := m["name"].(string) return name } func (c *Client) timeoutError(method string, params any, timeout time.Duration) error { if method == "tools/call" { if raw := rawToolNameFromCallParams(params); raw != "" { return fmt.Errorf("MCP tool %q timed out after %s; execution may have completed, so it was not retried automatically; increase tool_timeout_seconds or call_timeout_seconds to allow longer runs: %w", c.name+"."+raw, formatTimeout(timeout), context.DeadlineExceeded) } } return fmt.Errorf("MCP method %q on server %q timed out after %s; increase mcp_call_timeout_seconds or call_timeout_seconds to allow longer runs: %w", method, c.name, formatTimeout(timeout), context.DeadlineExceeded) } func formatTimeout(timeout time.Duration) string { if timeout > 0 && timeout%time.Second == 0 { return fmt.Sprintf("%ds", int(timeout/time.Second)) } return timeout.String() } // toolName builds Reasonix's canonical model-visible name // "mcp____". The registry separately resolves unique portable // and Claude plugin-qualified references without exposing duplicate schemas. func toolName(server, raw string) string { return ToolPrefix(server) + normalizeName(raw) } // ToolPrefix is the model-visible namespace prefix for every tool from server. func ToolPrefix(server string) string { return "mcp__" + normalizeName(server) + "__" } // MCPConnectPermissionName is the canonical permission and hook identity for // starting server on demand. It is intentionally outside the mcp__ tool // namespace: permission rules match tool names exactly, so a connect must have // its own non-colliding name instead of pretending a tool-prefix is a glob. func MCPConnectPermissionName(server string) string { return "mcp_connect__" + normalizeName(server) } // ModelToolName is the canonical model-visible name for server's raw tool — // including the collision-hash suffix normalizeName appends when the raw name // needed sanitising. Every permission/hook/audit surface that names an MCP // tool must build the name through this function; a second normalization that // skips the hash would let deny/ask rules written for the executed name miss. func ModelToolName(server, raw string) string { return toolName(server, raw) } var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) func normalizeName(s string) string { raw := s s = strings.Trim(invalidNameChars.ReplaceAllString(s, "_"), "_") if s == "" { s = "unnamed" } if s != raw { s += "_" + shortNameHash(raw) } return s } func shortNameHash(s string) string { h := fnv.New32a() _, _ = h.Write([]byte(s)) return fmt.Sprintf("%08x", h.Sum32())[:6] } func summarizeFailureError(err error) string { msg := strings.Join(strings.Fields(secrets.RedactCredentials(err.Error())), " ") const max = 500 if len(msg) > max { msg = msg[:max] + "..." } return msg } // remote tool adapter type remoteTool struct { client *Client name string // namespaced "mcp____" rawName string // original name for tools/call visibleName string // raw name after configured prefix stripping desc string schema json.RawMessage outputSchema json.RawMessage declaredReadOnly bool // server hint, independent of server authorization readOnly bool // effective reader classification for this live snapshot // destructive is the MCP destructiveHint. It takes precedence over a // conflicting readOnlyHint in Plan and strict read-only execution. destructive bool generation uint64 // Apps metadata: whether the App channel may call this tool and the ui // resource an App renders results with. visibility []string appCallable bool uiResourceURI string uiCSP map[string][]string } func (t *remoteTool) Name() string { return t.name } func (t *remoteTool) Description() string { return t.desc } func (t *remoteTool) MCPServerName() string { if t.client == nil { return "" } return t.client.name } func (t *remoteTool) MCPRawToolName() string { return t.rawName } func (t *remoteTool) MCPVisibleToolName() string { return t.visibleName } func (t *remoteTool) MCPPackageName() string { if t.client == nil { return "" } return t.client.spec.Package } // AppCallable reports whether an App instance may invoke this tool. func (t *remoteTool) AppCallable() bool { return t.appCallable } // UIResourceURI returns the MCP Apps _meta.ui.resourceUri (empty = none). func (t *remoteTool) UIResourceURI() string { return t.uiResourceURI } // UICSP returns the resource's declared CSP directives (nil = default deny). func (t *remoteTool) UICSP() map[string][]string { return t.uiCSP } func (t *remoteTool) MCPServerAuthorized() bool { return t.client != nil && t.client.spec.ServerAuthorized() } // ReadOnly reflects MCP readOnlyHint plus backward-compatible Spec overrides. // It defaults to false, so opaque tools remain write-capable unless the server // or local configuration explicitly classifies them as read-only. func (t *remoteTool) securitySnapshot() (declaredReadOnly, readOnly, destructive bool) { if t.client == nil { return t.declaredReadOnly, t.readOnly, t.destructive } t.client.toolsMu.RLock() defer t.client.toolsMu.RUnlock() return t.declaredReadOnly, t.readOnly, t.destructive } func (t *remoteTool) ReadOnly() bool { _, readOnly, _ := t.securitySnapshot() return readOnly } func (t *remoteTool) MCPDestructiveHint() bool { _, _, destructive := t.securitySnapshot() return destructive } func (t *remoteTool) Schema() json.RawMessage { if len(t.schema) == 0 { return json.RawMessage(`{"type":"object"}`) } return canonicalizeSchema(t.schema) } func (t *remoteTool) Execute(ctx context.Context, args json.RawMessage) (string, error) { text, _, err := t.ExecuteWithImages(ctx, args) return text, err } // ExecuteWithImages implements tool.ImageTool: MCP results may carry image // content items, which callers with a structural image channel (the agent) // forward to vision models instead of relying on the text placeholders alone. func (t *remoteTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) { res, err := t.callRaw(ctx, args) if err != nil { return "", nil, err } stampMCPAppResult(ctx, t, res) return parseToolResultWithSchema(res, t.outputSchema) } // ExecuteForApp returns the complete standard CallToolResult to an MCP App. // The text and isError flag are separate host-local projections used for hooks // and transcript events; an MCP isError result remains a successful bridge // response so the App receives its structured fields and metadata. func (t *remoteTool) ExecuteForApp(ctx context.Context, args json.RawMessage) (json.RawMessage, string, bool, error) { res, err := t.callRaw(ctx, args) if err != nil { return nil, "", false, err } res, err = tool.ValidateMCPAppCallResult(res) if err != nil { return nil, "", false, err } var status struct { IsError bool `json:"isError"` } if err := json.Unmarshal(res, &status); err != nil { return nil, "", false, fmt.Errorf("decode MCP App tool result: %w", err) } text, _, parseErr := parseToolResultForApp(res) if parseErr != nil && !status.IsError { return nil, "", false, parseErr } return res, text, status.IsError, nil } func (t *remoteTool) callRaw(ctx context.Context, args json.RawMessage) (json.RawMessage, error) { if t.client == nil { return nil, errors.New("MCP tool client is unavailable") } t.client.toolDispatchMu.RLock() defer t.client.toolDispatchMu.RUnlock() if t.client.closed.Load() { return nil, fmt.Errorf("MCP server %q is closed", t.client.name) } if t.client.toolCatalogStale() { t.client.ensureToolsRefresh() return nil, fmt.Errorf("MCP server %q changed its tool catalog and the refresh is still pending or failed; retry so Reasonix can apply the current schema and safety metadata", t.client.name) } if t.generation == 0 || t.generation != t.client.catalogGeneration { return nil, fmt.Errorf("MCP server %q changed tool %q after this call was authorized; retry so Reasonix can apply the current schema and safety metadata", t.client.name, t.rawName) } var argMap map[string]any if len(args) > 0 { if err := json.Unmarshal(args, &argMap); err != nil { return nil, fmt.Errorf("invalid args: %w", err) } } readOnly, destructive := t.readOnly, t.destructive if tool.HasReaderExecutionIntent(ctx) { // Final, linearizable check for a reader-authorized call: the snapshot // above and every catalog publication serialize on toolDispatchMu. A call // approved as a non-destructive reader must never execute after // authorization or safety metadata changed — state drift here returns an // actionable error instead of dispatching. if !t.MCPServerAuthorized() || !readOnly || destructive { return nil, fmt.Errorf("MCP server %q changed the authorization or security metadata for tool %q; the call was blocked before dispatch — refresh the server from a parent session before retrying", t.client.name, t.rawName) } } if tool.HasNonDestructiveMCPExecutionIntent(ctx) { // Planner lane: authorized + non-destructive only. Missing readOnlyHint // is intentional and does not block; destructive promotion or lost // authorization must produce zero tools/call. if !t.MCPServerAuthorized() || destructive { return nil, fmt.Errorf("MCP server %q changed the authorization or destructive classification for tool %q; the call was blocked before dispatch — retry so Reasonix can re-apply the current Planner MCP safety boundary", t.client.name, t.rawName) } } tool.ObserveRemoteDispatch(ctx) res, err := t.client.call(ctx, "tools/call", map[string]any{ "name": t.rawName, "arguments": argMap, }) if err != nil { return nil, err } return res, nil }