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