返回 DeepSeek-Reasonix
plugin.go
根目录 / internal / plugin / plugin.go
1 // Package plugin is Reasonix's MCP client. It connects to external MCP servers and
2 // adapts their tools to the tool.Tool interface, so the agent treats plugin tools
3 // and built-ins uniformly. The official MCP Go SDK owns protocol negotiation and
4 // JSON-RPC sessions across stdio, Streamable HTTP, and legacy HTTP+SSE; Reasonix
5 // retains product policy, lifecycle supervision, and transport security.
6 package plugin
7
8 import (
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "hash/fnv"
14 "io"
15 "log/slog"
16 "maps"
17 "net/http"
18 "reflect"
19 "regexp"
20 "strings"
21 "sync"
22 "sync/atomic"
23 "time"
24
25 "reasonix/internal/event"
26 "reasonix/internal/mcplaunch"
27 "reasonix/internal/sandbox"
28 "reasonix/internal/secrets"
29 "reasonix/internal/tool"
30 )
31
32 // MCPProcessMode selects how a local stdio MCP process is launched.
33 // It is an internal runtime field, not a user-facing config knob.
34 type MCPProcessMode string
35
36 const (
37 // MCPProcessHost runs authorized stdio MCP as a trusted host process that
38 // does not inherit the agent Bash command sandbox. This is the product
39 // default so servers such as chrome-devtools-mcp can reach the real browser,
40 // Keychain, LaunchServices, and local app services.
41 MCPProcessHost MCPProcessMode = "host"
42 // MCPProcessConfined wraps the process with sandbox.CommandArgs. Reserved for
43 // internal managed deployments and tests; never auto-selected for user installs.
44 MCPProcessConfined MCPProcessMode = "confined"
45 )
46
47 // ResolvedProcessMode returns the effective process mode. Empty means host.
48 func (s Spec) ResolvedProcessMode() MCPProcessMode {
49 switch s.ProcessMode {
50 case MCPProcessConfined:
51 return MCPProcessConfined
52 default:
53 return MCPProcessHost
54 }
55 }
56
57 // defaultCallTimeout is the MCP JSON-RPC call deadline applied when neither the
58 // caller context nor config provides one. It is intentionally finite so a slow
59 // or hung MCP server cannot block an agent turn indefinitely.
60 const defaultCallTimeout = 300 * time.Second
61
62 // Spec declares an external MCP server. Type selects the transport: "stdio"
63 // (default) runs Command/Args/Env as a subprocess; "http" / "streamable-http"
64 // and "sse" connect to URL with optional static Headers.
65 type Spec struct {
66 Name string
67 // Package is the installed plugin package that contributed this server.
68 // It is host-only provenance and intentionally excluded from fingerprints.
69 Package string
70 Type string
71 Command string
72 Args []string
73 Env map[string]string
74 URL string
75 Headers map[string]string
76 // DefaultStartupTimeout is the background initialize + tools/list safety cap
77 // for this server. Zero keeps Reasonix's built-in default.
78 DefaultStartupTimeout time.Duration
79 // StartupTimeout overrides DefaultStartupTimeout for this server. It is
80 // host-only lifecycle policy and never changes provider-visible tool schemas.
81 StartupTimeout time.Duration
82 // DefaultCallTimeout is the global MCP call cap for this server. Zero keeps
83 // Reasonix's built-in defaultCallTimeout.
84 DefaultCallTimeout time.Duration
85 // CallTimeout overrides DefaultCallTimeout for all calls to this server.
86 // Zero falls back to DefaultCallTimeout.
87 CallTimeout time.Duration
88 // ToolTimeouts overrides the per-call deadline for raw MCP tool names.
89 // Keys are server-local tool names as returned by tools/list, not the
90 // model-visible mcp__server__tool names.
91 ToolTimeouts map[string]time.Duration
92 // Dir, when set, is the working directory of a stdio subprocess. Empty means
93 // inherit reasonix's cwd (the default for user-configured plugins). It exists
94 // for cwd-aware servers like CodeGraph, which detect the project from the
95 // directory they are launched in — they must be pinned to the project root.
96 Dir string
97 // WorkspaceRoot is the project root exposed through the MCP roots capability.
98 // It is runtime-only and intentionally separate from Dir: user-installed
99 // stdio servers keep inheriting Reasonix's cwd while still receiving the
100 // explicit workspace root when they ask for roots/list.
101 WorkspaceRoot string
102 // Stderr optionally mirrors plugin subprocess stderr output. Stderr is always
103 // captured in a bounded buffer for failure diagnostics; nil keeps it out of
104 // the terminal so child logs cannot corrupt interactive UIs.
105 Stderr io.Writer
106 // LaunchManager owns exact project launch grants and mutable launcher locks.
107 // It never contributes to SchemaCacheKey or provider-visible tool schemas.
108 LaunchManager *mcplaunch.Manager
109 // ConfigSource disambiguates otherwise identical server names coming from
110 // workspace config, a host transport, or a user-installed plugin package.
111 ConfigSource string
112 // Authorized is the single runtime authorization result for this server.
113 // User-installed and explicit host-session servers set it directly; project
114 // servers set it only after an exact launch grant is resolved.
115 Authorized bool
116 RequireLaunchApproval bool
117 // LaunchArgs and launcher metadata are host-local immutable resolutions for
118 // mutable package launchers. LauncherIdentityArgs is the same exact package
119 // resolution without an automatically injected offline/no-install flag: that
120 // enforcement-only flag changes process invocation but not the server identity
121 // the user approved. These fields never contribute to SchemaCacheKey or the
122 // provider-visible tool surface; Args remains the user's stable config.
123 LaunchArgs []string
124 LauncherIdentityArgs []string
125 LauncherLocator string
126 LauncherResolvedVersion string
127 LauncherDigest string
128 // ProcessMode selects host mode (default) or confined mode, which is reserved
129 // for internal managed deployments and tests, never an automatic fallback.
130 ProcessMode MCPProcessMode
131 // Sandbox is only applied when ProcessMode is confined. Host-mode servers
132 // keep private state/cache/temp dirs without wrapping the process in the
133 // agent command sandbox.
134 Sandbox sandbox.Spec
135 StateDir string
136 OAuthHTTPClient *http.Client
137 // StripRawPrefix, when non-empty, removes this prefix from each MCP tool's
138 // raw name before namespacing. For example, StripRawPrefix="server_" turns
139 // "server_search" into "search", yielding "mcp__search__search" instead of
140 // the redundant "mcp__search__server_search". The original raw name is
141 // preserved for MCP protocol calls.
142 StripRawPrefix string
143 // LowPriority runs a stdio subprocess below normal scheduling priority, for
144 // background indexers that must not starve the user's machine.
145 LowPriority bool
146 }
147
148 // transport carries JSON-RPC messages to and from one MCP server. call sends a
149 // request and returns its result; close releases resources. Transports route MCP
150 // progress notifications to the active tool call and answer the client
151 // capabilities Reasonix advertises (currently ping and roots/list).
152 type transport interface {
153 call(ctx context.Context, method string, params any) (json.RawMessage, error)
154 close()
155 }
156
157 // Host owns the running plugin connections and closes them together. It also
158 // aggregates the prompts and resources discovered across servers, which the
159 // chat UI surfaces (prompts as slash commands, resources as @-references).
160 type Host struct {
161 // mu guards the slices below: StartAll builds the Host single-threaded, but
162 // after that a /mcp hot-add or -remove (one goroutine) can run concurrently
163 // with reads from a running turn's @ref resolution or the status UI.
164 mu sync.RWMutex
165 clients []*Client
166 prompts []Prompt
167 resources []Resource
168 failures []Failure
169 closed bool
170
171 // nextInstanceID assigns stable IDs to Client values appended to this Host.
172 // nextScopeID assigns IDs to per-build RegistrationScope tokens.
173 nextInstanceID atomic.Uint64
174 nextScopeID atomic.Uint64
175
176 // Lazy/background servers may still be handshaking when a session closes.
177 // Close cancels those startup contexts and waits for their goroutines before
178 // taking the client snapshot, so a just-connected stdio child cannot escape
179 // teardown and keep a Windows workspace directory locked.
180 deferredCancels map[string][]context.CancelFunc
181 deferredGenerations map[string]uint64
182 deferredWG sync.WaitGroup
183
184 // spawningMu + spawning prevent concurrent spawns of the same server from
185 // multiple callers (e.g. several controller tabs sharing one Host). The
186 // owner publishes its result before closing done so waiters can reuse the
187 // discovered tools without issuing concurrent tools/list calls.
188 spawningMu sync.Mutex
189 spawning map[string]*spawnAttempt
190
191 // proxies holds stable per-server backends for rolling replacement without
192 // changing provider-visible tool prefixes (spatiotemporal composability).
193 proxies map[string]*serverProxy
194
195 // profile is the host's semantic client-capability surface, fixed at
196 // creation; cache identity and the capability matrix derive from it.
197 profile HostProfile
198
199 // appInstances is the bounded MCP Apps instance registry, built with the
200 // Host and never nil.
201 appInstances *appInstanceRegistry
202
203 // Detached stats/schema-cache writers from Start; off the boot path but
204 // drained by Close so cleanup can't race a still-open cache file.
205 bgWrites sync.WaitGroup
206 surfaceWG sync.WaitGroup
207
208 toolListChanges toolListSubscriptions
209 }
210
211 // ReadResource reads a resource uri from the named server. It is how the chat
212 // UI resolves an @server:uri reference — the uri need not be one listed by
213 // resources/list (servers may expose templated uris), so we read it directly.
214 func (h *Host) ReadResource(ctx context.Context, server, uri string) (string, error) {
215 h.mu.RLock()
216 var target *Client
217 for _, c := range h.clients {
218 if c.name == server {
219 target = c
220 break
221 }
222 }
223 h.mu.RUnlock()
224 if target == nil {
225 return "", fmt.Errorf("no MCP server named %q", server)
226 }
227 return target.readResource(ctx, uri) // network call: outside the lock
228 }
229
230 // StartPolicy tunes batch plugin startup. The zero value disables every safeguard,
231 // so most call sites should use the StartAll / StartAvailable wrappers, which
232 // fill in production defaults.
233 type StartPolicy struct {
234 // PerPluginTimeout caps how long a single plugin's handshake (start +
235 // initialize + listTools + listPrompts/Resources) may take. Zero disables.
236 // Exceeded plugins are recorded as failures and, when AbortOnError is set,
237 // tear down the whole batch with the timeout as the cause.
238 PerPluginTimeout time.Duration
239
240 // Concurrency caps how many handshakes run at once. Zero or negative means
241 // no cap (every plugin gets a goroutine immediately). A small cap prevents
242 // process storms / FD exhaustion when many MCP servers are configured.
243 Concurrency int
244
245 // AbortOnError makes any single failure tear down the partial batch and
246 // return an error (StartAll semantics). When false, failures are recorded
247 // on the host and other plugins keep going (StartAvailable semantics).
248 AbortOnError bool
249
250 // SkipPersistence disables RecordStartup / SaveCachedSchema side effects.
251 // Use for read-only live probes (capability diagnostics) that must not
252 // write MCP stats or schema cache files under Reasonix home.
253 SkipPersistence bool
254 }
255
256 // defaultStartConcurrency caps parallel handshakes for the batch-start wrappers.
257 // Eight is the standard "process storm" guardrail (Bazel's --jobs=auto, most LSP
258 // managers) — large enough to mask single-plugin latency, small enough to spare
259 // a workstation with 20+ configured MCP servers from fork-bombing itself.
260 const defaultStartConcurrency = 8
261
262 // defaultStartTimeout is the per-plugin budget used by StartAvailable. Five
263 // seconds covers a healthy stdio MCP spawning under a slow npm/node loader; past
264 // that, an interactive user is better served by recording the failure and moving
265 // on than by stalling the whole session.
266 const defaultStartTimeout = 5 * time.Second
267
268 var advertisedToolsEmptyListRetryDelays = []time.Duration{
269 50 * time.Millisecond,
270 150 * time.Millisecond,
271 300 * time.Millisecond,
272 }
273
274 // ErrServerAlreadyConnected marks an attempted MCP connection whose server name
275 // is already live on the host.
276 var ErrServerAlreadyConnected = errors.New("plugin server already connected")
277
278 func serverAlreadyConnectedError(name string) error {
279 return fmt.Errorf("%w: %q", ErrServerAlreadyConnected, name)
280 }
281
282 // IsServerAlreadyConnected reports whether err means the MCP server name is
283 // already live on the host.
284 func IsServerAlreadyConnected(err error) bool {
285 return errors.Is(err, ErrServerAlreadyConnected)
286 }
287
288 // StartAll connects every plugin in parallel, performs the MCP handshake, and
289 // returns the union of their tools (namespaced "mcp__<server>__<tool>"). On any
290 // failure it tears down everything started so far. The caller must Close the Host.
291 //
292 // For stdio plugins, subprocess lifetime is bound to ctx (via
293 // exec.CommandContext): cancelling ctx kills the children and unblocks reads.
294 func StartAll(ctx context.Context, specs []Spec) (*Host, []tool.Tool, error) {
295 return Start(ctx, specs, StartPolicy{
296 Concurrency: defaultStartConcurrency,
297 AbortOnError: true,
298 })
299 }
300
301 // StartAvailable connects every plugin it can and records failures on the host
302 // instead of aborting the whole session. The returned tools are the union of the
303 // successfully connected servers.
304 func StartAvailable(ctx context.Context, specs []Spec) (*Host, []tool.Tool) {
305 h, tools, _ := Start(ctx, specs, StartPolicy{
306 PerPluginTimeout: defaultStartTimeout,
307 Concurrency: defaultStartConcurrency,
308 // AbortOnError stays false: a misconfigured plugin must not bring down
309 // the whole session at boot.
310 })
311 return h, tools
312 }
313
314 // Start is the unified batch-startup primitive behind StartAll / StartAvailable.
315 // It fans out handshakes in parallel under the policy's concurrency cap, gives
316 // each plugin its own per-plugin timeout, and either aborts the batch on first
317 // failure (AbortOnError=true) or records failures on the host and keeps going.
318 //
319 // Result ordering matches specs (stable for /mcp status). For stdio plugins the
320 // subprocess is bound to the parent ctx, not the per-plugin startup timeout:
321 // successful servers stay alive after startup, while failed/time-limited starts
322 // are closed explicitly before the goroutine returns.
323 func Start(ctx context.Context, specs []Spec, p StartPolicy) (*Host, []tool.Tool, error) {
324 if len(specs) == 0 {
325 return &Host{}, nil, nil
326 }
327
328 type result struct {
329 idx int
330 spec Spec
331 client *Client
332 tools []tool.Tool
333 err error
334 }
335
336 // A buffered channel acts as a counting semaphore. Capacity 0/negative
337 // means no cap — we still launch one goroutine per spec, but they all run
338 // immediately. Capped, the extra goroutines block on the semaphore until a
339 // slot frees up; collection order is still by idx so /mcp status is stable.
340 concurrency := p.Concurrency
341 if concurrency <= 0 || concurrency > len(specs) {
342 concurrency = len(specs)
343 }
344 sem := make(chan struct{}, concurrency)
345 ch := make(chan result, len(specs))
346
347 // Created before the fan-out so the detached cache writers can join bgWrites.
348 h := &Host{}
349
350 for i, s := range specs {
351 go func(idx int, spec Spec) {
352 sem <- struct{}{}
353 defer func() { <-sem }()
354
355 callCtx := ctx
356 cancelStartup := func() {}
357 if p.PerPluginTimeout > 0 {
358 var cancel context.CancelFunc
359 callCtx, cancel = context.WithTimeout(ctx, p.PerPluginTimeout)
360 cancelStartup = cancel
361 }
362
363 phaseAStart := time.Now()
364 recordedPhaseADur := func() time.Duration {
365 dur := time.Since(phaseAStart)
366 if p.PerPluginTimeout > 0 && callCtx.Err() == context.DeadlineExceeded && dur < p.PerPluginTimeout {
367 return p.PerPluginTimeout
368 }
369 return dur
370 }
371
372 // Transport on the parent ctx, startup RPCs on the timed callCtx: the
373 // per-plugin timeout caps initialize+listTools, but the long-lived
374 // stdio child must outlive the startup scope and later phase-B calls.
375 c, err := start(ctx, callCtx, spec, h.profile)
376 if err != nil {
377 phaseADur := recordedPhaseADur()
378 cancelStartup()
379 if !p.SkipPersistence {
380 h.bgWrites.Go(func() { ; _ = RecordStartup(spec.Name, phaseADur) })
381 }
382 ch <- result{idx: idx, spec: spec, err: fmt.Errorf("start plugin %q: %w", spec.Name, err)}
383 return
384 }
385 h.bindToolListChanges(c)
386 ts, err := c.listTools(callCtx)
387 if err != nil {
388 phaseADur := recordedPhaseADur()
389 cancelStartup()
390 if !p.SkipPersistence {
391 h.bgWrites.Go(func() { ; _ = RecordStartup(spec.Name, phaseADur) })
392 }
393 c.close()
394 err = newStartupFailure("tools/list", phaseAStart, c.startupStderr(), err)
395 ch <- result{idx: idx, spec: spec, err: fmt.Errorf("list tools from %q: %w", spec.Name, err)}
396 return
397 }
398 // Persist for next launch on the side: a slow stats/cache write
399 // must not delay tools coming online, and either failure is
400 // recoverable (we just re-handshake or skip auto-demote).
401 phaseADur := recordedPhaseADur()
402 cancelStartup()
403 if !p.SkipPersistence {
404 h.bgWrites.Go(func() {
405 _ = RecordStartup(spec.Name, phaseADur)
406 _ = SaveCachedSchemaForProfile(h.profile, spec.Name, CachedSchema{
407 CacheKey: SchemaCacheKey(spec),
408 Capabilities: map[string]bool{
409 "tools": c.capabilities.tools,
410 "prompts": c.capabilities.prompts,
411 "resources": c.capabilities.resources,
412 },
413 Tools: cacheableToolsOf(ts),
414 })
415 })
416 }
417
418 // Prompts and resources are deferred to StartPhaseB so the boot path
419 // can return as soon as tools are ready — the slow-to-list surfaces
420 // stream in later and fan out an MCPSurfaceReady event each.
421 ch <- result{idx: idx, spec: spec, client: c, tools: ts}
422 }(i, s)
423 }
424
425 // Wait for every goroutine even on abort: started clients sit beyond a
426 // failing index, so we need them all back to tear them down in Close().
427 results := make([]result, len(specs))
428 for range specs {
429 r := <-ch
430 results[r.idx] = r
431 }
432
433 var tools []tool.Tool
434 var firstErr error
435 for _, r := range results {
436 if r.err != nil {
437 if p.AbortOnError {
438 if firstErr == nil {
439 firstErr = r.err
440 }
441 } else {
442 h.RecordFailure(r.spec, r.err)
443 }
444 continue
445 }
446 current, err := h.registerStartedClient(r.client, r.tools)
447 if err != nil {
448 r.client.close()
449 if firstErr == nil {
450 firstErr = err
451 }
452 continue
453 }
454 tools = append(tools, current...)
455 // prompts/resources are filled in later by StartPhaseB.
456 }
457 if firstErr != nil {
458 h.Close()
459 return nil, nil, firstErr
460 }
461 return h, tools, nil
462 }
463
464 // Close terminates all plugin connections.
465 func (h *Host) Close() {
466 h.mu.Lock()
467 if h.closed {
468 h.mu.Unlock()
469 return
470 }
471 h.closed = true
472 var cancels []context.CancelFunc
473 for _, serverCancels := range h.deferredCancels {
474 cancels = append(cancels, serverCancels...)
475 }
476 h.deferredCancels = nil
477 h.mu.Unlock()
478
479 for _, cancel := range cancels {
480 cancel()
481 }
482 h.deferredWG.Wait()
483
484 h.mu.Lock()
485 clients := append([]*Client(nil), h.clients...)
486 proxies := h.proxies
487 h.proxies = nil
488 h.clients = nil
489 h.toolListChanges.subscribers = nil
490 h.mu.Unlock()
491 closeServerProxies(proxies)
492 for _, c := range clients {
493 if c != nil && c.t != nil {
494 c.close()
495 }
496 }
497 h.surfaceWG.Wait()
498 h.bgWrites.Wait() // drain detached stats/schema writers before returning
499 }
500
501 // queueBackgroundWrite keeps detached persistence inside the Host lifecycle.
502 // Callers must enqueue before their Close-drained startup owner completes, so
503 // Close cannot begin waiting before the WaitGroup increment is visible.
504 func (h *Host) queueBackgroundWrite(write func()) {
505 h.bgWrites.Go(func() {
506 write()
507 })
508 }
509
510 func (h *Host) goSurface(work func()) bool {
511 if h == nil || work == nil {
512 return false
513 }
514 h.mu.Lock()
515 if h.closed {
516 h.mu.Unlock()
517 return false
518 }
519 h.surfaceWG.Add(1)
520 h.mu.Unlock()
521 go func() {
522 defer h.surfaceWG.Done()
523 work()
524 }()
525 return true
526 }
527
528 // StartPhaseB asynchronously fetches the auxiliary surfaces (prompts and
529 // resources) for every connected client. Boot calls it right after Start
530 // returns, on a session-scoped ctx, so the agent becomes responsive as soon as
531 // tools are ready and the slower list calls stream in afterwards. Each finished
532 // surface fires an MCPSurfaceReady event on sink so UIs (e.g. /mcp status) can
533 // refresh without polling. A nil sink is tolerated — the merge still happens.
534 // Errors are logged and swallowed: prompts/resources are non-essential and must
535 // not break the session over one slow server.
536 func (h *Host) StartPhaseB(ctx context.Context, sink event.Sink) {
537 h.mu.RLock()
538 clients := append([]*Client(nil), h.clients...)
539 h.mu.RUnlock()
540 for _, c := range clients {
541 if c.capabilities.prompts {
542 h.goSurface(func() { h.fetchPrompts(ctx, c, sink) })
543 }
544 if c.capabilities.resources {
545 h.goSurface(func() { h.fetchResources(ctx, c, sink) })
546 }
547 }
548 }
549
550 func (h *Host) fetchPrompts(ctx context.Context, c *Client, sink event.Sink) {
551 auxCtx, cancel := context.WithTimeout(ctx, defaultStartTimeout)
552 defer cancel()
553
554 ps, err := c.listPrompts(auxCtx)
555 if err != nil {
556 if ctx.Err() == nil && !c.closed.Load() {
557 slog.Warn("plugin: listPrompts failed", "server", c.name, "err", err)
558 }
559 return
560 }
561 for i := range ps {
562 ps[i].client = c
563 }
564 h.mu.Lock()
565 if h.closed || h.lookupClientLocked(c.name) != c {
566 h.mu.Unlock()
567 return
568 }
569 h.removeClientPromptsLocked(c)
570 c.prompts = ps
571 h.prompts = append(h.prompts, ps...)
572 h.mu.Unlock()
573 if sink != nil {
574 sink.Emit(event.Event{
575 Kind: event.MCPSurfaceReady,
576 Text: fmt.Sprintf("%s: prompts ready (%d items)", c.name, len(ps)),
577 })
578 }
579 }
580
581 func (h *Host) fetchResources(ctx context.Context, c *Client, sink event.Sink) {
582 auxCtx, cancel := context.WithTimeout(ctx, defaultStartTimeout)
583 defer cancel()
584
585 rs, err := c.listResources(auxCtx)
586 if err != nil {
587 if ctx.Err() == nil && !c.closed.Load() {
588 slog.Warn("plugin: listResources failed", "server", c.name, "err", err)
589 }
590 return
591 }
592 h.mu.Lock()
593 if h.closed || h.lookupClientLocked(c.name) != c {
594 h.mu.Unlock()
595 return
596 }
597 h.removeClientResourcesLocked(c)
598 c.resources = rs
599 h.resources = append(h.resources, rs...)
600 h.mu.Unlock()
601 if sink != nil {
602 sink.Emit(event.Event{
603 Kind: event.MCPSurfaceReady,
604 Text: fmt.Sprintf("%s: resources ready (%d items)", c.name, len(rs)),
605 })
606 }
607 }
608
609 func (h *Host) removeClientPromptsLocked(c *Client) {
610 kept := h.prompts[:0]
611 for _, prompt := range h.prompts {
612 if prompt.client != c {
613 kept = append(kept, prompt)
614 }
615 }
616 h.prompts = kept
617 }
618
619 func (h *Host) removeClientResourcesLocked(c *Client) {
620 kept := h.resources[:0]
621 for _, resource := range h.resources {
622 if resource.Server != c.name {
623 kept = append(kept, resource)
624 }
625 }
626 h.resources = kept
627 }
628
629 // Client is one MCP server connection plus Reasonix's product-facing catalogs.
630 // MCP operations are transport-agnostic and go through the supervised SDK session.
631 type Client struct {
632 name string
633 instanceID uint64 // Host-local identity for RemoveIfInstance rollback
634 t transport
635 spec Spec
636 profile HostProfile
637
638 // registrationClaims and registrationCommitted are guarded by Host.mu.
639 // Claims keep a tentative shared instance alive across overlapping builds;
640 // the first published controller promotes it to ordinary Host ownership.
641 registrationClaims map[uint64]struct{}
642 registrationCommitted bool
643
644 // Advertised surface and list-changed capabilities are kept together so
645 // initialization publishes one coherent capability snapshot.
646 capabilities clientCapabilities
647 protocolVersion string
648 transport string // declared transport type, for /mcp status ("stdio"/"http")
649
650 // Prompts and resources discovered during StartAll, stored here so the
651 // parallel startup can collect them per-client before merging into Host.
652 prompts []Prompt
653 resources []Resource
654 // toolListFetchMu serializes tools/list requests. It may span the remote
655 // request; toolsMu never does, so status readers cannot be stalled by MCP I/O.
656 toolListFetchMu sync.Mutex
657 toolsMu sync.RWMutex
658 toolCatalog toolCatalogSnapshot
659
660 // toolDispatchMu linearizes final adapter validation with tools/call and
661 // catalog publication. A notification marks the catalog stale atomically,
662 // so calls that have not entered this gate fail closed while it refreshes.
663 toolDispatchMu sync.RWMutex
664 catalogGeneration uint64 // guarded by toolDispatchMu
665 closed atomic.Bool
666 closeOnce sync.Once
667 refresh toolListRefreshState
668 surfaceStopsMu sync.Mutex
669 surfaceStops []func()
670 auxiliaryRefresh auxiliaryListRefreshState
671 progressID atomic.Uint64
672 }
673
674 // AuthorizeSpecLaunch records durable consent for an explicitly user-installed
675 // project MCP without starting it a second time. The normal project discovery
676 // path still requires a user action; install_source calls this only while
677 // applying a plan the user already requested. Reuse an existing launcher lock
678 // when one exists, but do not add a second network/version-resolution step to an
679 // explicit install: the durable grant follows the exact configured command or
680 // endpoint and future changes still invalidate it.
681 func AuthorizeSpecLaunch(ctx context.Context, spec Spec) error {
682 return authorizeSpecLaunch(ctx, spec, false)
683 }
684
685 // AuthorizeProjectSpecLaunch records the one durable launch confirmation used
686 // for repository-discovered MCP configuration. Mutable package launchers are
687 // resolved and locked, but the MCP server itself is not started: the caller can
688 // connect it exactly once after this function returns.
689 func AuthorizeProjectSpecLaunch(ctx context.Context, spec Spec) error {
690 return authorizeSpecLaunch(ctx, spec, true)
691 }
692
693 func authorizeSpecLaunch(ctx context.Context, spec Spec, lockMutableLauncher bool) error {
694 if !spec.RequireLaunchApproval {
695 return nil
696 }
697 manager := spec.LaunchManager
698 if manager == nil {
699 return fmt.Errorf("MCP launch authorization store is unavailable")
700 }
701 var prepared Spec
702 var launcherLock *mcplaunch.LauncherLock
703 var err error
704 if lockMutableLauncher {
705 prepared, launcherLock, err = preparePersistentLauncher(ctx, spec)
706 } else {
707 prepared, err = applyStoredLauncherLock(spec)
708 }
709 if err != nil {
710 return err
711 }
712 identityDigest, err := projectLaunchIdentityDigest(ctx, prepared)
713 if err != nil {
714 return err
715 }
716 if launcherLock != nil {
717 // Store the resolution before the grant so a failed state write cannot
718 // leave an authorization whose exact launcher identity is unavailable.
719 if err := manager.PutLauncherLock(*launcherLock); err != nil {
720 return err
721 }
722 }
723 return manager.Authorize(prepared.Name, launchConfigSource(prepared), identityDigest)
724 }
725
726 // Failure records one MCP server that was configured but could not connect.
727 type Failure struct {
728 Name string
729 Transport string
730 Error string
731 Stage string
732 Elapsed time.Duration
733 Stderr string
734 RequiresLaunchApproval bool
735 }
736
737 type launchApprovalError struct {
738 server string
739 changed bool
740 }
741
742 func (e *launchApprovalError) Error() string {
743 if e.changed {
744 return fmt.Sprintf("project-provided MCP server %q changed; blocked before process or network startup and requires explicit re-authorization", e.server)
745 }
746 return fmt.Sprintf("project-provided MCP server %q is blocked before process or network startup until the user authorizes it", e.server)
747 }
748
749 func requiresLaunchApproval(err error) bool {
750 var launchTarget *launchApprovalError
751 return errors.As(err, &launchTarget)
752 }
753
754 // Servers returns a status summary per connected server, in connection order.
755 func (h *Host) Servers() []ServerStatus {
756 h.mu.RLock()
757 defer h.mu.RUnlock()
758 out := make([]ServerStatus, 0, len(h.clients))
759 for _, c := range h.clients {
760 c.toolsMu.RLock()
761 s := ServerStatus{
762 Name: c.name,
763 Transport: c.transport,
764 ConfigSource: strings.TrimSpace(c.spec.ConfigSource),
765 Tools: len(c.toolCatalog.adapters),
766 HasTools: c.capabilities.tools,
767 }
768 fillServerNegotiation(&s, h.profile, c)
769 s.ToolList = append([]ToolInfo(nil), c.toolCatalog.infos...)
770 adapters := append([]tool.Tool(nil), c.toolCatalog.adapters...)
771 c.toolsMu.RUnlock()
772 for _, adapter := range adapters {
773 if binding, ok := tool.MCPBindingOf(adapter); ok {
774 s.ToolBindings = append(s.ToolBindings, binding)
775 }
776 }
777 if provider, ok := c.t.(sessionDiagnosticsProvider); ok {
778 diagnostics := provider.sessionDiagnostics()
779 s.ProtocolVersion = diagnostics.ProtocolVersion
780 s.SessionState = diagnostics.State
781 s.SessionIDPresent = diagnostics.SessionIDPresent
782 s.ReconnectAttempts = diagnostics.ReconnectAttempts
783 s.LastErrorKind = diagnostics.LastErrorKind
784 s.LastError = diagnostics.LastError
785 }
786 for _, p := range h.prompts {
787 if p.Server == c.name {
788 s.Prompts++
789 }
790 }
791 for _, r := range h.resources {
792 if r.Server == c.name {
793 s.Resources++
794 }
795 }
796 out = append(out, s)
797 }
798 return out
799 }
800
801 // RecordFailure stores a failed MCP connection attempt for status UIs.
802 func (h *Host) RecordFailure(s Spec, err error) {
803 h.mu.Lock()
804 defer h.mu.Unlock()
805 tt := strings.ToLower(strings.TrimSpace(s.Type))
806 if tt == "" {
807 tt = "stdio"
808 }
809 stage, elapsed, stderr := startupFailureDetails(err)
810 f := Failure{
811 Name: s.Name, Transport: tt, Error: summarizeFailureError(err),
812 Stage: stage, Elapsed: elapsed, Stderr: stderr,
813 RequiresLaunchApproval: requiresLaunchApproval(err),
814 }
815 for i := range h.failures {
816 if h.failures[i].Name == s.Name {
817 h.failures[i] = f
818 return
819 }
820 }
821 h.failures = append(h.failures, f)
822 }
823
824 // RecordLaunchApprovalRequired keeps an intentionally disconnected project MCP
825 // visible as awaiting authorization. This is used after an explicit launch
826 // revocation, where no failed connection attempt exists to create the status.
827 func (h *Host) RecordLaunchApprovalRequired(s Spec) {
828 h.RecordFailure(s, &launchApprovalError{server: s.Name})
829 }
830
831 // ClearFailure drops a recorded startup/connection failure for status UIs.
832 func (h *Host) ClearFailure(name string) {
833 h.mu.Lock()
834 defer h.mu.Unlock()
835 h.clearFailure(name)
836 }
837
838 // clearFailure drops the failure record for name. The caller holds h.mu (Lock) —
839 // it runs inside addConnected / Remove, which already mutate under the lock.
840 func (h *Host) clearFailure(name string) {
841 kept := h.failures[:0]
842 for _, f := range h.failures {
843 if f.Name != name {
844 kept = append(kept, f)
845 }
846 }
847 h.failures = kept
848 }
849
850 // NewHost returns an empty core-v1 Host. Boot always constructs one — even
851 // with no plugins configured — so servers can be hot-added later via Add (the
852 // `/mcp add` command), keeping the controller's host pointer stable.
853 func NewHost() *Host { return NewHostWithProfile(HostProfileCore) }
854
855 // NewHostWithProfile returns an empty Host declaring the given profile's
856 // client capabilities. Immutable once set; a degraded frontend constructs
857 // the Host with a lower profile instead of mutating a live one.
858 func NewHostWithProfile(profile HostProfile) *Host {
859 return &Host{profile: profile.Normalize(), appInstances: newAppInstanceRegistry()}
860 }
861
862 // Profile returns the host's semantic capability profile, fixed at creation.
863 func (h *Host) Profile() HostProfile { return h.profile.Normalize() }
864
865 func (h *Host) registerDeferredCancel(name string, cancel context.CancelFunc) uint64 {
866 h.mu.Lock()
867 defer h.mu.Unlock()
868 if h.closed {
869 cancel()
870 return 0
871 }
872 if h.deferredCancels == nil {
873 h.deferredCancels = make(map[string][]context.CancelFunc)
874 }
875 if h.deferredGenerations == nil {
876 h.deferredGenerations = make(map[string]uint64)
877 }
878 generation := h.deferredGenerations[name]
879 if generation == 0 {
880 generation = 1
881 h.deferredGenerations[name] = generation
882 }
883 h.deferredCancels[name] = append(h.deferredCancels[name], cancel)
884 return generation
885 }
886
887 func (h *Host) beginDeferredSpawn() bool {
888 h.mu.Lock()
889 defer h.mu.Unlock()
890 if h.closed {
891 return false
892 }
893 h.deferredWG.Add(1)
894 return true
895 }
896
897 func (h *Host) endDeferredSpawn() {
898 h.deferredWG.Done()
899 }
900
901 // ErrSpawningInFlight is returned by Host.Add when another caller is already
902 // spawning the same server on this host. The caller should retry later.
903 var ErrSpawningInFlight = errors.New("server spawn already in progress")
904
905 type spawnAttempt struct {
906 server string
907 done chan struct{}
908 tools []tool.Tool
909 err error
910 }
911
912 // ConnectionResult is the eventual result of a session-owned background MCP
913 // handshake. Tools are provider adapters and remain off the caller's registry
914 // unless the caller explicitly registers them.
915 type ConnectionResult struct {
916 Tools []tool.Tool
917 Err error
918 }
919
920 // EnsureConnectedInBackground starts or joins one shared initialize +
921 // tools/list handshake owned by lifeCtx. The returned channel is buffered, so a
922 // caller may stop waiting while the server continues toward readiness. Host
923 // shutdown and Remove cancel the background work and wait for its goroutine.
924 func (h *Host) EnsureConnectedInBackground(lifeCtx context.Context, s Spec) <-chan ConnectionResult {
925 result := make(chan ConnectionResult, 1)
926 startupBase, cancelStartupBase := context.WithCancel(lifeCtx)
927 generation := h.registerDeferredCancel(s.Name, cancelStartupBase)
928 if !h.beginDeferredSpawn() {
929 cancelStartupBase()
930 result <- ConnectionResult{Err: fmt.Errorf("plugin host is closed")}
931 return result
932 }
933 go func() {
934 defer h.endDeferredSpawn()
935 defer cancelStartupBase()
936 started := time.Now()
937 startupCtx, cancelStartup := context.WithTimeout(startupBase, s.startupTimeout())
938 tools, err := h.EnsureConnectedWithLifecycle(lifeCtx, startupCtx, s, generation)
939 cancelStartup()
940 if err != nil {
941 err = newStartupFailure("connect", started, "", err)
942 if !errors.Is(err, context.Canceled) && !errors.Is(err, ErrDeferredSpawnCancelled) {
943 h.RecordFailure(s, err)
944 }
945 }
946 result <- ConnectionResult{Tools: tools, Err: err}
947 }()
948 return result
949 }
950
951 // beginSpawn atomically claims the sole right to spawn the named server.
952 // Returns owner=true if the caller should proceed. When another caller is
953 // already spawning the same server, owner=false and done is closed when that
954 // spawn finishes.
955 func (h *Host) beginSpawn(key, server string) (*spawnAttempt, bool) {
956 h.spawningMu.Lock()
957 defer h.spawningMu.Unlock()
958 if h.spawning == nil {
959 h.spawning = make(map[string]*spawnAttempt)
960 }
961 if attempt, ok := h.spawning[key]; ok {
962 return attempt, false
963 }
964 attempt := &spawnAttempt{server: server, done: make(chan struct{})}
965 h.spawning[key] = attempt
966 return attempt, true
967 }
968
969 // endSpawn releases the spawn claim for the named server.
970 func (h *Host) endSpawn(name string, tools []tool.Tool, err error) {
971 h.spawningMu.Lock()
972 if attempt, ok := h.spawning[name]; ok {
973 attempt.tools = append([]tool.Tool(nil), tools...)
974 attempt.err = err
975 delete(h.spawning, name)
976 close(attempt.done)
977 }
978 h.spawningMu.Unlock()
979 }
980
981 // has reports whether a server with this name is already connected.
982 func (h *Host) has(name string) bool {
983 h.mu.RLock()
984 defer h.mu.RUnlock()
985 return h.hasLocked(name)
986 }
987
988 func (h *Host) hasLocked(name string) bool {
989 for _, c := range h.clients {
990 if c.name == name {
991 return true
992 }
993 }
994 return false
995 }
996
997 // HasClient reports whether a server with this name is already connected to the host.
998 func (h *Host) HasClient(name string) bool { return h.has(name) }
999
1000 // HasClientForSpec reports whether the shared Host client for spec.Name was
1001 // created from the same runtime connection identity. Server names are only a
1002 // display/routing namespace; they are not sufficient authorization identity
1003 // when controllers with different project configs share one Host.
1004 func (h *Host) HasClientForSpec(spec Spec) bool {
1005 c := h.client(spec.Name)
1006 return c != nil && MCPRuntimeSpecMatches(c.spec, spec)
1007 }
1008
1009 // ToolsFor returns the namespaced tool instances for an already-connected client.
1010 // ctx bounds the tools/list call so a non-responsive server does not hang
1011 // permanently. An error is returned when no client with that name is connected.
1012 func (h *Host) ToolsFor(ctx context.Context, name string) ([]tool.Tool, error) {
1013 h.mu.RLock()
1014 closed := h.closed
1015 h.mu.RUnlock()
1016 if closed {
1017 return nil, fmt.Errorf("plugin host is closed")
1018 }
1019
1020 // Attempt to resolve via the existing Client.
1021 c := h.client(name)
1022 if c == nil {
1023 return nil, fmt.Errorf("client %q not found on shared host", name)
1024 }
1025 if err := h.claimClientFromContext(ctx, c); err != nil {
1026 return nil, err
1027 }
1028 if tools, ok := c.cachedTools(); ok {
1029 return tools, nil
1030 }
1031 return c.listTools(ctx)
1032 }
1033
1034 // ToolsForSpec is the identity-bound variant used by stable capability
1035 // frontends. It refuses a same-name client from another controller, project
1036 // identity, endpoint, or prior hot-update generation instead of treating that
1037 // client as the current runtime's authorized server.
1038 func (h *Host) ToolsForSpec(ctx context.Context, spec Spec) ([]tool.Tool, error) {
1039 h.mu.RLock()
1040 closed := h.closed
1041 h.mu.RUnlock()
1042 if closed {
1043 return nil, fmt.Errorf("plugin host is closed")
1044 }
1045 c := h.client(spec.Name)
1046 if c == nil {
1047 return nil, fmt.Errorf("client %q not found on shared host", spec.Name)
1048 }
1049 if !MCPRuntimeSpecMatches(c.spec, spec) {
1050 return nil, fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration", spec.Name)
1051 }
1052 if err := h.claimClientFromContext(ctx, c); err != nil {
1053 return nil, err
1054 }
1055 if tools, ok := c.cachedTools(); ok {
1056 return tools, nil
1057 }
1058 return c.listTools(ctx)
1059 }
1060
1061 // MCPRuntimeSpecMatches compares the complete host-local runtime behavior of
1062 // two specs while deliberately excluding non-behavioral handles such as the
1063 // stderr writer and LaunchManager pointer. Secret values are compared only in
1064 // memory and are never serialized into diagnostics or provider-visible state.
1065 func MCPRuntimeSpecMatches(a, b Spec) bool {
1066 return reflect.DeepEqual(mcpRuntimeSpecIdentityOf(a), mcpRuntimeSpecIdentityOf(b))
1067 }
1068
1069 // MCPToolMatchesSpec reports whether a concrete plugin adapter or pinned lazy
1070 // placeholder belongs to the requested runtime spec. Unknown tool
1071 // implementations fail closed when a runtime-bound capability frontend asks.
1072 func MCPToolMatchesSpec(t tool.Tool, spec Spec) bool {
1073 switch typed := t.(type) {
1074 case *remoteTool:
1075 return typed != nil && typed.client != nil && MCPRuntimeSpecMatches(typed.client.spec, spec)
1076 case *lazyTool:
1077 return typed != nil && typed.shared != nil && MCPRuntimeSpecMatches(typed.shared.spec, spec)
1078 default:
1079 return false
1080 }
1081 }
1082
1083 type mcpRuntimeSpecIdentity struct {
1084 Name string
1085 Package string
1086 Type string
1087 Command string
1088 Args []string
1089 Env map[string]string
1090 URL string
1091 Headers map[string]string
1092 DefaultStartupTimeout time.Duration
1093 StartupTimeout time.Duration
1094 DefaultCallTimeout time.Duration
1095 CallTimeout time.Duration
1096 ToolTimeouts map[string]time.Duration
1097 Dir string
1098 WorkspaceRoot string
1099 LaunchWorkspace string
1100 ConfigSource string
1101 RequireLaunchApproval bool
1102 LaunchArgs []string
1103 LauncherIdentityArgs []string
1104 LauncherLocator string
1105 LauncherResolvedVersion string
1106 LauncherDigest string
1107 ProcessMode MCPProcessMode
1108 Sandbox sandbox.Spec
1109 StateDir string
1110 StripRawPrefix string
1111 LowPriority bool
1112 }
1113
1114 func mcpRuntimeSpecIdentityOf(s Spec) mcpRuntimeSpecIdentity {
1115 launchWorkspace := ""
1116 if s.LaunchManager != nil {
1117 launchWorkspace = s.LaunchManager.WorkspaceFingerprint()
1118 }
1119 return mcpRuntimeSpecIdentity{
1120 Name: strings.TrimSpace(s.Name),
1121 Package: strings.TrimSpace(s.Package),
1122 Type: canonicalMCPRuntimeTransport(s.Type),
1123 Command: s.Command,
1124 Args: nonEmptyStrings(s.Args),
1125 Env: nonEmptyStringMap(s.Env),
1126 URL: s.URL,
1127 Headers: nonEmptyStringMap(s.Headers),
1128 DefaultStartupTimeout: s.DefaultStartupTimeout,
1129 StartupTimeout: s.StartupTimeout,
1130 DefaultCallTimeout: s.DefaultCallTimeout,
1131 CallTimeout: s.CallTimeout,
1132 ToolTimeouts: nonEmptyDurationMap(s.ToolTimeouts),
1133 Dir: s.Dir,
1134 WorkspaceRoot: s.WorkspaceRoot,
1135 LaunchWorkspace: launchWorkspace,
1136 ConfigSource: strings.TrimSpace(s.ConfigSource),
1137 RequireLaunchApproval: s.RequireLaunchApproval,
1138 LaunchArgs: nonEmptyStrings(s.LaunchArgs),
1139 LauncherIdentityArgs: nonEmptyStrings(s.LauncherIdentityArgs),
1140 LauncherLocator: s.LauncherLocator,
1141 LauncherResolvedVersion: s.LauncherResolvedVersion,
1142 LauncherDigest: s.LauncherDigest,
1143 ProcessMode: s.ResolvedProcessMode(),
1144 Sandbox: canonicalMCPRuntimeSandbox(s.Sandbox),
1145 StateDir: s.StateDir,
1146 StripRawPrefix: s.StripRawPrefix,
1147 LowPriority: s.LowPriority,
1148 }
1149 }
1150
1151 func canonicalMCPRuntimeTransport(raw string) string {
1152 switch strings.ToLower(strings.TrimSpace(raw)) {
1153 case "", "stdio":
1154 return "stdio"
1155 case "http", "streamable-http", "streamable_http":
1156 return "streamable-http"
1157 case "sse":
1158 return "sse"
1159 default:
1160 return strings.ToLower(strings.TrimSpace(raw))
1161 }
1162 }
1163
1164 func canonicalMCPRuntimeSandbox(in sandbox.Spec) sandbox.Spec {
1165 in.WriteRoots = nonEmptyStrings(in.WriteRoots)
1166 in.ForbidReadRoots = nonEmptyStrings(in.ForbidReadRoots)
1167 return in
1168 }
1169
1170 func nonEmptyStrings(in []string) []string {
1171 if len(in) == 0 {
1172 return nil
1173 }
1174 return in
1175 }
1176
1177 func nonEmptyStringMap(in map[string]string) map[string]string {
1178 if len(in) == 0 {
1179 return nil
1180 }
1181 return in
1182 }
1183
1184 func nonEmptyDurationMap(in map[string]time.Duration) map[string]time.Duration {
1185 if len(in) == 0 {
1186 return nil
1187 }
1188 return in
1189 }
1190
1191 func (h *Host) client(name string) *Client { return h.lookupClient(name) }
1192
1193 // Add connects one server live: it performs the MCP handshake, discovers the
1194 // server's tools (and prompts/resources when advertised), appends it to the
1195 // host, and returns its namespaced tools for the caller to register. ctx bounds a
1196 // stdio child's lifetime, so pass the session-scoped context — not a per-turn one
1197 // — or the subprocess dies when that turn ends. Errors if the name is taken.
1198 func (h *Host) Add(ctx context.Context, s Spec) ([]tool.Tool, error) {
1199 return h.addWithLifecycle(ctx, ctx, s, 0)
1200 }
1201
1202 // EnsureConnected returns tools for an already-connected server, or starts the
1203 // shared single-flight handshake and waits for it. Concurrent callers for the
1204 // same server share one initialize/tools-list; cancelling a waiter only cancels
1205 // that wait and never kills a process still used by other runtimes.
1206 func (h *Host) EnsureConnected(ctx context.Context, s Spec) ([]tool.Tool, error) {
1207 return h.EnsureConnectedWithLifecycle(ctx, ctx, s, 0)
1208 }
1209
1210 // EnsureConnectedWithLifecycle is EnsureConnected with separate subprocess
1211 // lifetime (lifeCtx) and startup/call (callCtx) contexts, plus an optional
1212 // deferred generation for lazy registration.
1213 func (h *Host) EnsureConnectedWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) {
1214 if deferredGeneration != 0 && !h.deferredGenerationCurrent(s.Name, deferredGeneration) {
1215 return nil, ErrDeferredSpawnCancelled
1216 }
1217 if tools, err := h.ToolsFor(callCtx, s.Name); err == nil {
1218 return tools, nil
1219 }
1220 tools, err := h.addWithLifecycle(lifeCtx, callCtx, s, deferredGeneration)
1221 if IsServerAlreadyConnected(err) {
1222 return h.ToolsFor(callCtx, s.Name)
1223 }
1224 return tools, err
1225 }
1226
1227 // AddWithLifecycle connects one server live, allowing caller to specify separate
1228 // contexts for the subprocess lifecycle (lifeCtx, session-scoped) and the startup
1229 // handshake/list calls (callCtx, turn-scoped/timeout-bound).
1230 func (h *Host) AddWithLifecycle(lifeCtx, callCtx context.Context, s Spec) ([]tool.Tool, error) {
1231 return h.addWithLifecycle(lifeCtx, callCtx, s, 0)
1232 }
1233
1234 func (h *Host) addWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) {
1235 if deferredGeneration != 0 && !h.deferredGenerationCurrent(s.Name, deferredGeneration) {
1236 return nil, ErrDeferredSpawnCancelled
1237 }
1238 if h.has(s.Name) {
1239 return nil, serverAlreadyConnectedError(s.Name)
1240 }
1241 spawnKey := s.Name
1242 if deferredGeneration != 0 {
1243 spawnKey = fmt.Sprintf("%s#%d", s.Name, deferredGeneration)
1244 }
1245 attempt, owner := h.beginSpawn(spawnKey, s.Name)
1246 if !owner {
1247 select {
1248 case <-attempt.done:
1249 if attempt.err != nil {
1250 return nil, attempt.err
1251 }
1252 return append([]tool.Tool(nil), attempt.tools...), nil
1253 case <-callCtx.Done():
1254 return nil, callCtx.Err()
1255 case <-lifeCtx.Done():
1256 return nil, lifeCtx.Err()
1257 }
1258 }
1259 var tools []tool.Tool
1260 var err error
1261 defer func() { h.endSpawn(spawnKey, tools, err) }()
1262 // Double-check after acquiring the spawn token: another caller may have
1263 // connected the server between our h.has check and beginSpawn.
1264 if h.has(s.Name) {
1265 err = serverAlreadyConnectedError(s.Name)
1266 return nil, err
1267 }
1268 tools, err = h.addConnectedWithLifecycle(lifeCtx, callCtx, s, deferredGeneration)
1269 return tools, err
1270 }
1271
1272 func (h *Host) addConnected(ctx context.Context, s Spec) ([]tool.Tool, error) {
1273 return h.addConnectedWithLifecycle(ctx, ctx, s, 0)
1274 }
1275
1276 func (h *Host) addConnectedWithLifecycle(lifeCtx, callCtx context.Context, s Spec, deferredGeneration uint64) ([]tool.Tool, error) {
1277 startupStarted := time.Now()
1278 h.mu.RLock()
1279 if h.closed {
1280 h.mu.RUnlock()
1281 return nil, fmt.Errorf("plugin host is closed")
1282 }
1283 h.mu.RUnlock()
1284
1285 c, err := start(lifeCtx, callCtx, s, h.profile)
1286 if err != nil {
1287 return nil, err
1288 }
1289 h.bindToolListChanges(c)
1290 ts, err := c.listTools(callCtx)
1291 if err != nil {
1292 c.close()
1293 err = newStartupFailure("tools/list", startupStarted, c.startupStderr(), err)
1294 return nil, fmt.Errorf("list tools: %w", err)
1295 }
1296 h.mu.Lock()
1297 if h.closed {
1298 h.mu.Unlock()
1299 c.close()
1300 return nil, fmt.Errorf("plugin host is closed")
1301 }
1302 if deferredGeneration != 0 && h.deferredGenerations[s.Name] != deferredGeneration {
1303 h.mu.Unlock()
1304 c.close()
1305 return nil, ErrDeferredSpawnCancelled
1306 }
1307 if h.hasLocked(s.Name) {
1308 h.mu.Unlock()
1309 c.close()
1310 return nil, serverAlreadyConnectedError(s.Name)
1311 }
1312 // Attribute ownership from lifeCtx so LazyToolset background kicks and
1313 // boot.Build share the same RegistrationScope token. Sibling hot-adds
1314 // without a scope are never journaled to a concurrent build.
1315 if err := h.noteClientFromContext(lifeCtx, c); err != nil {
1316 h.mu.Unlock()
1317 c.close()
1318 return nil, err
1319 }
1320 h.clearFailure(s.Name)
1321 h.mu.Unlock()
1322 if cached, ok := c.cachedTools(); ok {
1323 ts = cached
1324 }
1325 // Prompts and resources stream in on the long lifeCtx the caller passed (Host.Add
1326 // uses the session-scoped PluginCtx, not a per-turn ctx), so the slow list
1327 // calls cannot starve a /mcp add of its return value. nil sink keeps hot-add
1328 // quiet — the chat UI re-queries Host.Prompts()/Resources() on demand.
1329 if c.capabilities.prompts {
1330 h.goSurface(func() { h.fetchPrompts(lifeCtx, c, nil) })
1331 }
1332 if c.capabilities.resources {
1333 h.goSurface(func() { h.fetchResources(lifeCtx, c, nil) })
1334 }
1335 return ts, nil
1336 }
1337
1338 // Remove disconnects the named server and drops its prompts/resources, returning
1339 // the namespaced tool-name prefix ("mcp__<server>__") the caller unregisters from
1340 // the tool registry, and whether the server was connected.
1341 func (h *Host) Remove(name string) (toolPrefix string, found bool) {
1342 h.mu.Lock()
1343 cancels := append([]context.CancelFunc(nil), h.deferredCancels[name]...)
1344 delete(h.deferredCancels, name)
1345 if h.deferredGenerations == nil {
1346 h.deferredGenerations = make(map[string]uint64)
1347 }
1348 h.deferredGenerations[name]++
1349 if h.deferredGenerations[name] == 0 {
1350 h.deferredGenerations[name] = 1
1351 }
1352 idx := -1
1353 for i, c := range h.clients {
1354 if c.name == name {
1355 idx = i
1356 break
1357 }
1358 }
1359 if idx < 0 {
1360 h.mu.Unlock()
1361 for _, cancel := range cancels {
1362 cancel()
1363 }
1364 if len(cancels) == 0 {
1365 return "", false
1366 }
1367 return ToolPrefix(name), true
1368 }
1369 removed := h.removeClientAtLocked(idx)
1370 if h.appInstances != nil {
1371 h.appInstances.ReleaseServer(name)
1372 }
1373 h.mu.Unlock()
1374
1375 for _, cancel := range cancels {
1376 cancel()
1377 }
1378 removed.close() // kills the subprocess: outside the lock
1379
1380 return "mcp__" + normalizeName(name) + "__", true
1381 }
1382
1383 func (h *Host) deferredGenerationCurrent(name string, generation uint64) bool {
1384 h.mu.RLock()
1385 defer h.mu.RUnlock()
1386 return !h.closed && generation != 0 && h.deferredGenerations[name] == generation
1387 }
1388
1389 // ErrDeferredSpawnCancelled marks a lazy generation invalidated by remove or
1390 // host shutdown before it could publish a client.
1391 var ErrDeferredSpawnCancelled = errors.New("deferred MCP spawn cancelled")
1392
1393 // start opens the transport on lifeCtx (whose cancellation later closes the
1394 // subprocess) and uses callCtx for the initialize round-trip (whose cancellation
1395 // only bounds startup RPCs). Splitting the two lets a per-plugin timeout cap
1396 // handshake latency without making the timeout context own a successfully
1397 // registered stdio server; the child also has to outlive phase A so phase B
1398 // (prompts + resources) can still call it later. Callers that don't care pass
1399 // the same ctx for both.
1400 func start(lifeCtx, callCtx context.Context, s Spec, profile HostProfile) (*Client, error) {
1401 started := time.Now()
1402 var err error
1403 s, err = applyStoredLauncherLock(s)
1404 if err != nil {
1405 return nil, newStartupFailure("launch", started, "", err)
1406 }
1407 s, err = resolveProjectLaunchAuthorization(callCtx, s)
1408 if err != nil {
1409 return nil, newStartupFailure("authorization", started, "", err)
1410 }
1411 t, err := newTransport(lifeCtx, s, profile)
1412 if err != nil {
1413 return nil, newStartupFailure("launch", started, "", err)
1414 }
1415 tt := strings.ToLower(strings.TrimSpace(s.Type))
1416 if tt == "" {
1417 tt = "stdio"
1418 }
1419 refreshCtx := lifeCtx
1420 if refreshCtx == nil {
1421 refreshCtx = context.Background()
1422 }
1423 refreshCtx, cancelRefresh := context.WithCancel(refreshCtx)
1424 c := &Client{
1425 name: s.Name,
1426 t: t,
1427 spec: s,
1428 profile: profile.Normalize(),
1429 transport: tt,
1430 refresh: toolListRefreshState{
1431 ctx: refreshCtx,
1432 cancel: cancelRefresh,
1433 },
1434 }
1435 if err := c.initialize(callCtx); err != nil {
1436 c.close()
1437 err = newStartupFailure("initialize", started, c.startupStderr(), err)
1438 return nil, err
1439 }
1440 return c, nil
1441 }
1442
1443 // resolveProjectLaunchAuthorization deliberately skips identity resolution for
1444 // installed and host-session servers. Their explicit installation is already
1445 // the authorization decision; only repository-declared servers need an exact
1446 // executable or endpoint digest before startup.
1447 func resolveProjectLaunchAuthorization(ctx context.Context, s Spec) (Spec, error) {
1448 if !s.RequireLaunchApproval {
1449 return s, nil
1450 }
1451 identityDigest, err := projectLaunchIdentityDigest(ctx, s)
1452 if err != nil {
1453 return s, err
1454 }
1455 return applyEstablishedLaunchGrant(s, identityDigest)
1456 }
1457
1458 func applyEstablishedLaunchGrant(s Spec, identityDigest string) (Spec, error) {
1459 if !s.RequireLaunchApproval {
1460 return s, nil
1461 }
1462 if s.LaunchManager == nil {
1463 return s, fmt.Errorf("MCP launch authorization store is unavailable")
1464 }
1465 authorized, changed, err := s.LaunchManager.LaunchAuthorized(s.Name, launchConfigSource(s), identityDigest)
1466 if err != nil {
1467 return s, err
1468 }
1469 if !authorized {
1470 return s, &launchApprovalError{server: s.Name, changed: changed}
1471 }
1472 // A matching exact-identity launch grant is the user's authorization for
1473 // this project server. Calls proceed like an explicit install, while global
1474 // deny rules and execution safety boundaries remain authoritative.
1475 s.Authorized = true
1476 return s, nil
1477 }
1478
1479 // ResolveStoredAuthorization applies an existing exact project grant without
1480 // starting a process or opening a network connection. Cached lazy/on-demand
1481 // tools use it before strict read-only filtering so every execution path sees
1482 // the same server-level authorization. Errors fail closed by returning the
1483 // original unauthorized Spec; a parent connection surfaces the detailed error.
1484 func ResolveStoredAuthorization(ctx context.Context, s Spec) Spec {
1485 if !s.RequireLaunchApproval {
1486 return s
1487 }
1488 locked, err := applyStoredLauncherLock(s)
1489 if err != nil {
1490 return s
1491 }
1492 authorized, err := resolveProjectLaunchAuthorization(ctx, locked)
1493 if err != nil {
1494 return s
1495 }
1496 return authorized
1497 }
1498
1499 // ServerAuthorized is the single MCP authorization source. Tools do not carry
1500 // an independent trust bit: installation or an exact project launch grant
1501 // authorizes the server, while read-only/destructive classification remains a
1502 // live per-tool safety fact.
1503 func (s Spec) ServerAuthorized() bool {
1504 return s.Authorized
1505 }
1506
1507 // newTransport builds the transport for a spec's declared type. Empty / unknown
1508 // defaults to stdio.
1509 func newTransport(ctx context.Context, s Spec, profile HostProfile) (transport, error) {
1510 return newSDKSessionTransport(ctx, s, profile)
1511 }
1512
1513 func (c *Client) call(ctx context.Context, method string, params any) (json.RawMessage, error) {
1514 params, unregisterProgress := c.withProgress(ctx, method, params)
1515 defer unregisterProgress()
1516
1517 callCtx, cancel, timeout := c.contextWithCallTimeout(ctx, method, params)
1518 if cancel != nil {
1519 defer cancel()
1520 }
1521
1522 started := time.Now()
1523 res, err := c.callTransport(callCtx, method, params)
1524 c.observeProtocol(method, res, time.Since(started), err)
1525 if timeout > 0 && errors.Is(err, context.DeadlineExceeded) && callCtx.Err() == context.DeadlineExceeded && ctx.Err() == nil {
1526 slog.Warn("plugin: MCP call timed out",
1527 "server", c.name, "method", method, "tool", rawToolNameFromCallParams(params), "timeout", timeout)
1528 return nil, c.timeoutError(method, params, timeout)
1529 }
1530 return res, err
1531 }
1532
1533 func (c *Client) withProgress(ctx context.Context, method string, params any) (any, func()) {
1534 if method != "tools/call" {
1535 return params, func() {}
1536 }
1537 sink, ok := tool.ProgressFrom(ctx)
1538 if !ok {
1539 return params, func() {}
1540 }
1541 router, ok := c.t.(progressTransport)
1542 if !ok {
1543 return params, func() {}
1544 }
1545 callParams, ok := params.(map[string]any)
1546 if !ok {
1547 return params, func() {}
1548 }
1549
1550 token := fmt.Sprintf("reasonix-%d", c.progressID.Add(1))
1551 copyParams := make(map[string]any, len(callParams))
1552 maps.Copy(copyParams, callParams)
1553 meta := map[string]any{}
1554 if existing, ok := callParams["_meta"].(map[string]any); ok {
1555 maps.Copy(meta, existing)
1556 }
1557 meta["progressToken"] = token
1558 copyParams["_meta"] = meta
1559 unregister := router.registerProgress(token, sink)
1560 return copyParams, unregister
1561 }
1562
1563 func (c *Client) callTransport(ctx context.Context, method string, params any) (json.RawMessage, error) {
1564 return c.t.call(ctx, method, params)
1565 }
1566
1567 func (c *Client) contextWithCallTimeout(ctx context.Context, method string, params any) (context.Context, context.CancelFunc, time.Duration) {
1568 if _, ok := ctx.Deadline(); ok {
1569 return ctx, nil, 0
1570 }
1571 timeout := c.callTimeout(method, params)
1572 if timeout <= 0 {
1573 timeout = defaultCallTimeout
1574 }
1575 callCtx, cancel := context.WithTimeout(ctx, timeout)
1576 return callCtx, cancel, timeout
1577 }
1578
1579 func (c *Client) callTimeout(method string, params any) time.Duration {
1580 if method == "tools/call" {
1581 if raw := rawToolNameFromCallParams(params); raw != "" {
1582 if timeout := c.spec.ToolTimeouts[raw]; timeout > 0 {
1583 return timeout
1584 }
1585 }
1586 }
1587 if c.spec.CallTimeout > 0 {
1588 return c.spec.CallTimeout
1589 }
1590 if c.spec.DefaultCallTimeout > 0 {
1591 return c.spec.DefaultCallTimeout
1592 }
1593 return defaultCallTimeout
1594 }
1595
1596 func rawToolNameFromCallParams(params any) string {
1597 m, ok := params.(map[string]any)
1598 if !ok {
1599 return ""
1600 }
1601 name, _ := m["name"].(string)
1602 return name
1603 }
1604
1605 func (c *Client) timeoutError(method string, params any, timeout time.Duration) error {
1606 if method == "tools/call" {
1607 if raw := rawToolNameFromCallParams(params); raw != "" {
1608 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",
1609 c.name+"."+raw, formatTimeout(timeout), context.DeadlineExceeded)
1610 }
1611 }
1612 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",
1613 method, c.name, formatTimeout(timeout), context.DeadlineExceeded)
1614 }
1615
1616 func formatTimeout(timeout time.Duration) string {
1617 if timeout > 0 && timeout%time.Second == 0 {
1618 return fmt.Sprintf("%ds", int(timeout/time.Second))
1619 }
1620 return timeout.String()
1621 }
1622
1623 // toolName builds Reasonix's canonical model-visible name
1624 // "mcp__<server>__<tool>". The registry separately resolves unique portable
1625 // and Claude plugin-qualified references without exposing duplicate schemas.
1626 func toolName(server, raw string) string {
1627 return ToolPrefix(server) + normalizeName(raw)
1628 }
1629
1630 // ToolPrefix is the model-visible namespace prefix for every tool from server.
1631 func ToolPrefix(server string) string {
1632 return "mcp__" + normalizeName(server) + "__"
1633 }
1634
1635 // MCPConnectPermissionName is the canonical permission and hook identity for
1636 // starting server on demand. It is intentionally outside the mcp__ tool
1637 // namespace: permission rules match tool names exactly, so a connect must have
1638 // its own non-colliding name instead of pretending a tool-prefix is a glob.
1639 func MCPConnectPermissionName(server string) string {
1640 return "mcp_connect__" + normalizeName(server)
1641 }
1642
1643 // ModelToolName is the canonical model-visible name for server's raw tool —
1644 // including the collision-hash suffix normalizeName appends when the raw name
1645 // needed sanitising. Every permission/hook/audit surface that names an MCP
1646 // tool must build the name through this function; a second normalization that
1647 // skips the hash would let deny/ask rules written for the executed name miss.
1648 func ModelToolName(server, raw string) string {
1649 return toolName(server, raw)
1650 }
1651
1652 var invalidNameChars = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
1653
1654 func normalizeName(s string) string {
1655 raw := s
1656 s = strings.Trim(invalidNameChars.ReplaceAllString(s, "_"), "_")
1657 if s == "" {
1658 s = "unnamed"
1659 }
1660 if s != raw {
1661 s += "_" + shortNameHash(raw)
1662 }
1663 return s
1664 }
1665
1666 func shortNameHash(s string) string {
1667 h := fnv.New32a()
1668 _, _ = h.Write([]byte(s))
1669 return fmt.Sprintf("%08x", h.Sum32())[:6]
1670 }
1671
1672 func summarizeFailureError(err error) string {
1673 msg := strings.Join(strings.Fields(secrets.RedactCredentials(err.Error())), " ")
1674 const max = 500
1675 if len(msg) > max {
1676 msg = msg[:max] + "..."
1677 }
1678 return msg
1679 }
1680
1681 // remote tool adapter
1682
1683 type remoteTool struct {
1684 client *Client
1685 name string // namespaced "mcp__<server>__<tool>"
1686 rawName string // original name for tools/call
1687 visibleName string // raw name after configured prefix stripping
1688 desc string
1689 schema json.RawMessage
1690 outputSchema json.RawMessage
1691 declaredReadOnly bool // server hint, independent of server authorization
1692 readOnly bool // effective reader classification for this live snapshot
1693 // destructive is the MCP destructiveHint. It takes precedence over a
1694 // conflicting readOnlyHint in Plan and strict read-only execution.
1695 destructive bool
1696 generation uint64
1697 // Apps metadata: whether the App channel may call this tool and the ui
1698 // resource an App renders results with.
1699 visibility []string
1700 appCallable bool
1701 uiResourceURI string
1702 uiCSP map[string][]string
1703 }
1704
1705 func (t *remoteTool) Name() string { return t.name }
1706 func (t *remoteTool) Description() string { return t.desc }
1707 func (t *remoteTool) MCPServerName() string {
1708 if t.client == nil {
1709 return ""
1710 }
1711 return t.client.name
1712 }
1713 func (t *remoteTool) MCPRawToolName() string { return t.rawName }
1714 func (t *remoteTool) MCPVisibleToolName() string { return t.visibleName }
1715 func (t *remoteTool) MCPPackageName() string {
1716 if t.client == nil {
1717 return ""
1718 }
1719 return t.client.spec.Package
1720 }
1721
1722 // AppCallable reports whether an App instance may invoke this tool.
1723 func (t *remoteTool) AppCallable() bool { return t.appCallable }
1724
1725 // UIResourceURI returns the MCP Apps _meta.ui.resourceUri (empty = none).
1726 func (t *remoteTool) UIResourceURI() string { return t.uiResourceURI }
1727
1728 // UICSP returns the resource's declared CSP directives (nil = default deny).
1729 func (t *remoteTool) UICSP() map[string][]string { return t.uiCSP }
1730
1731 func (t *remoteTool) MCPServerAuthorized() bool {
1732 return t.client != nil && t.client.spec.ServerAuthorized()
1733 }
1734
1735 // ReadOnly reflects MCP readOnlyHint plus backward-compatible Spec overrides.
1736 // It defaults to false, so opaque tools remain write-capable unless the server
1737 // or local configuration explicitly classifies them as read-only.
1738 func (t *remoteTool) securitySnapshot() (declaredReadOnly, readOnly, destructive bool) {
1739 if t.client == nil {
1740 return t.declaredReadOnly, t.readOnly, t.destructive
1741 }
1742 t.client.toolsMu.RLock()
1743 defer t.client.toolsMu.RUnlock()
1744 return t.declaredReadOnly, t.readOnly, t.destructive
1745 }
1746
1747 func (t *remoteTool) ReadOnly() bool {
1748 _, readOnly, _ := t.securitySnapshot()
1749 return readOnly
1750 }
1751
1752 func (t *remoteTool) MCPDestructiveHint() bool {
1753 _, _, destructive := t.securitySnapshot()
1754 return destructive
1755 }
1756
1757 func (t *remoteTool) Schema() json.RawMessage {
1758 if len(t.schema) == 0 {
1759 return json.RawMessage(`{"type":"object"}`)
1760 }
1761 return canonicalizeSchema(t.schema)
1762 }
1763
1764 func (t *remoteTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
1765 text, _, err := t.ExecuteWithImages(ctx, args)
1766 return text, err
1767 }
1768
1769 // ExecuteWithImages implements tool.ImageTool: MCP results may carry image
1770 // content items, which callers with a structural image channel (the agent)
1771 // forward to vision models instead of relying on the text placeholders alone.
1772 func (t *remoteTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
1773 res, err := t.callRaw(ctx, args)
1774 if err != nil {
1775 return "", nil, err
1776 }
1777 stampMCPAppResult(ctx, t, res)
1778 return parseToolResultWithSchema(res, t.outputSchema)
1779 }
1780
1781 // ExecuteForApp returns the complete standard CallToolResult to an MCP App.
1782 // The text and isError flag are separate host-local projections used for hooks
1783 // and transcript events; an MCP isError result remains a successful bridge
1784 // response so the App receives its structured fields and metadata.
1785 func (t *remoteTool) ExecuteForApp(ctx context.Context, args json.RawMessage) (json.RawMessage, string, bool, error) {
1786 res, err := t.callRaw(ctx, args)
1787 if err != nil {
1788 return nil, "", false, err
1789 }
1790 res, err = tool.ValidateMCPAppCallResult(res)
1791 if err != nil {
1792 return nil, "", false, err
1793 }
1794 var status struct {
1795 IsError bool `json:"isError"`
1796 }
1797 if err := json.Unmarshal(res, &status); err != nil {
1798 return nil, "", false, fmt.Errorf("decode MCP App tool result: %w", err)
1799 }
1800 text, _, parseErr := parseToolResultForApp(res)
1801 if parseErr != nil && !status.IsError {
1802 return nil, "", false, parseErr
1803 }
1804 return res, text, status.IsError, nil
1805 }
1806
1807 func (t *remoteTool) callRaw(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
1808 if t.client == nil {
1809 return nil, errors.New("MCP tool client is unavailable")
1810 }
1811 t.client.toolDispatchMu.RLock()
1812 defer t.client.toolDispatchMu.RUnlock()
1813 if t.client.closed.Load() {
1814 return nil, fmt.Errorf("MCP server %q is closed", t.client.name)
1815 }
1816 if t.client.toolCatalogStale() {
1817 t.client.ensureToolsRefresh()
1818 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)
1819 }
1820 if t.generation == 0 || t.generation != t.client.catalogGeneration {
1821 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)
1822 }
1823 var argMap map[string]any
1824 if len(args) > 0 {
1825 if err := json.Unmarshal(args, &argMap); err != nil {
1826 return nil, fmt.Errorf("invalid args: %w", err)
1827 }
1828 }
1829 readOnly, destructive := t.readOnly, t.destructive
1830 if tool.HasReaderExecutionIntent(ctx) {
1831 // Final, linearizable check for a reader-authorized call: the snapshot
1832 // above and every catalog publication serialize on toolDispatchMu. A call
1833 // approved as a non-destructive reader must never execute after
1834 // authorization or safety metadata changed — state drift here returns an
1835 // actionable error instead of dispatching.
1836 if !t.MCPServerAuthorized() || !readOnly || destructive {
1837 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)
1838 }
1839 }
1840 if tool.HasNonDestructiveMCPExecutionIntent(ctx) {
1841 // Planner lane: authorized + non-destructive only. Missing readOnlyHint
1842 // is intentional and does not block; destructive promotion or lost
1843 // authorization must produce zero tools/call.
1844 if !t.MCPServerAuthorized() || destructive {
1845 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)
1846 }
1847 }
1848 tool.ObserveRemoteDispatch(ctx)
1849 res, err := t.client.call(ctx, "tools/call", map[string]any{
1850 "name": t.rawName,
1851 "arguments": argMap,
1852 })
1853 if err != nil {
1854 return nil, err
1855 }
1856 return res, nil
1857 }
1858
1858 lines GO