返回 DeepSeek-Reasonix
usecapability.go
根目录 / internal / agent / usecapability.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "sort"
8 "strings"
9 "sync"
10 "time"
11
12 "reasonix/internal/capability"
13 "reasonix/internal/config"
14 "reasonix/internal/event"
15 "reasonix/internal/plugin"
16 "reasonix/internal/tool"
17 )
18
19 // MCPCapabilityRuntime is the session-shared MCP substrate: Host, boot specs,
20 // provider-visible registry for already-registered tools, schema cache catalog,
21 // and live connection snapshots. Each agent (executor, planner, task/fleet
22 // child) gets its own UseCapabilityTool frontend so ledger/audit never cross
23 // agent boundaries, while process connections remain on the shared Host.
24 type MCPCapabilityRuntime struct {
25 lifeCtx context.Context
26 host *plugin.Host
27 registry *tool.Registry
28 catalog func() capability.Catalog
29
30 // dispatchMu linearizes server enable/spec mutations against MCP process
31 // startup and tools/call. Calls may run concurrently under RLock; a disable,
32 // uninstall, or hot update waits for in-flight dispatch and invalidates every
33 // target that has not begun its final runtime-bound execution check.
34 dispatchMu sync.RWMutex
35 mu sync.RWMutex
36 servers map[string]mcpRuntimeServer
37 // shared connection observation across all frontends on this session.
38 state *mcpProxySharedState
39 }
40
41 type mcpRuntimeServer struct {
42 entry config.PluginEntry
43 spec plugin.Spec
44 enabled bool
45 cached []plugin.CachedTool
46 cacheKeyOK bool
47 }
48
49 type mcpProxySharedState struct {
50 mu sync.Mutex
51 connected map[string]bool
52 liveTools map[string][]plugin.CachedTool
53 }
54
55 // NewMCPCapabilityRuntime builds the session-shared MCP substrate. lifeCtx owns
56 // on-demand MCP child process lifetimes; specs must be the boot-converted specs.
57 func NewMCPCapabilityRuntime(lifeCtx context.Context, host *plugin.Host, specs []plugin.Spec, reg *tool.Registry, catalog func() capability.Catalog) *MCPCapabilityRuntime {
58 r := &MCPCapabilityRuntime{
59 lifeCtx: lifeCtx,
60 host: host,
61 registry: reg,
62 catalog: catalog,
63 servers: map[string]mcpRuntimeServer{},
64 state: &mcpProxySharedState{connected: map[string]bool{}},
65 }
66 r.ConfigureServers(nil, specs, nil)
67 return r
68 }
69
70 // ConfigureServers replaces the runtime's configured MCP inventory. enabled is
71 // keyed by server name; nil keeps the standalone/test default that every spec is
72 // enabled. Boot passes the activation-resolved set so disabled servers are
73 // visible to discovery but cannot reuse a sibling tab's shared Host client.
74 func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, specs []plugin.Spec, enabled map[string]bool) {
75 if r == nil {
76 return
77 }
78 r.dispatchMu.Lock()
79 defer r.dispatchMu.Unlock()
80 byName := make(map[string]config.PluginEntry, len(entries))
81 for _, entry := range entries {
82 name := strings.TrimSpace(entry.Name)
83 if name != "" {
84 byName[name] = runtimePluginEntry(entry)
85 }
86 }
87 next := make(map[string]mcpRuntimeServer, len(specs))
88 for _, raw := range specs {
89 spec := cloneMCPSpec(raw)
90 name := strings.TrimSpace(spec.Name)
91 if name == "" {
92 continue
93 }
94 entry, ok := byName[name]
95 if !ok {
96 entry = config.PluginEntry{Name: name}
97 }
98 isEnabled := true
99 if enabled != nil {
100 isEnabled = enabled[name]
101 }
102 cached, keyOK := cachedToolsForSpec(spec)
103 next[name] = mcpRuntimeServer{
104 entry: entry,
105 spec: spec,
106 enabled: isEnabled,
107 cached: cached,
108 cacheKeyOK: keyOK,
109 }
110 }
111 r.mu.Lock()
112 r.servers = next
113 r.mu.Unlock()
114 for name := range next {
115 if !next[name].enabled {
116 r.state.clearServer(name)
117 }
118 }
119 }
120
121 // UpsertServer makes a hot-added or updated MCP spec authoritative for every
122 // frontend on this controller. Dynamic state stays host-local and never changes
123 // the provider-visible use_capability schema.
124 func (r *MCPCapabilityRuntime) UpsertServer(entry config.PluginEntry, raw plugin.Spec, enabled bool) {
125 if r == nil {
126 return
127 }
128 r.dispatchMu.Lock()
129 defer r.dispatchMu.Unlock()
130 spec := cloneMCPSpec(raw)
131 name := strings.TrimSpace(spec.Name)
132 if name == "" {
133 return
134 }
135 entry = runtimePluginEntry(entry)
136 if strings.TrimSpace(entry.Name) == "" {
137 entry.Name = name
138 }
139 cached, keyOK := cachedToolsForSpec(spec)
140 r.mu.Lock()
141 r.servers[name] = mcpRuntimeServer{
142 entry: entry,
143 spec: spec,
144 enabled: enabled,
145 cached: cached,
146 cacheKeyOK: keyOK,
147 }
148 r.mu.Unlock()
149 // Endpoint/tool metadata may have changed. Never route a stale live snapshot
150 // across an update; a connected client or the next call will repopulate it.
151 r.state.clearServer(name)
152 }
153
154 // SetServerEnabled revokes or restores this controller's right to use a server.
155 // It is intentionally independent from Host connectivity because desktop tabs
156 // may share one Host while keeping different enable states.
157 func (r *MCPCapabilityRuntime) SetServerEnabled(name string, enabled bool) bool {
158 if r == nil {
159 return false
160 }
161 r.dispatchMu.Lock()
162 defer r.dispatchMu.Unlock()
163 name = strings.TrimSpace(name)
164 r.mu.Lock()
165 server, ok := r.servers[name]
166 if ok {
167 server.enabled = enabled
168 r.servers[name] = server
169 }
170 r.mu.Unlock()
171 if ok && !enabled {
172 r.state.clearServer(name)
173 }
174 return ok
175 }
176
177 // RemoveServer removes an uninstalled/runtime-only MCP from discovery and
178 // clears any live tool snapshot that could otherwise keep it routable.
179 func (r *MCPCapabilityRuntime) RemoveServer(name string) bool {
180 if r == nil {
181 return false
182 }
183 r.dispatchMu.Lock()
184 defer r.dispatchMu.Unlock()
185 name = strings.TrimSpace(name)
186 r.mu.Lock()
187 _, ok := r.servers[name]
188 delete(r.servers, name)
189 r.mu.Unlock()
190 r.state.clearServer(name)
191 return ok
192 }
193
194 // CatalogState returns deterministic, privacy-minimal routing inputs for this
195 // controller. Configuration secrets are never copied into the transient route.
196 func (r *MCPCapabilityRuntime) CatalogState() (entries []config.PluginEntry, cached map[string][]plugin.CachedTool, keyOK map[string]bool, disabled map[string]bool) {
197 if r == nil {
198 return nil, nil, nil, nil
199 }
200 r.dispatchMu.RLock()
201 defer r.dispatchMu.RUnlock()
202 return r.catalogStateLocked()
203 }
204
205 // CapabilityCatalogState returns configuration and live proxy tools from one
206 // lifecycle generation. Callers must use this combined snapshot when building
207 // a route: taking the two halves separately can otherwise pair a just-updated
208 // spec with a stale pre-update live-tool directory.
209 func (r *MCPCapabilityRuntime) CapabilityCatalogState() (entries []config.PluginEntry, cached map[string][]plugin.CachedTool, keyOK map[string]bool, disabled map[string]bool, proxyTools map[string][]plugin.CachedTool) {
210 if r == nil {
211 return nil, nil, nil, nil, nil
212 }
213 r.dispatchMu.RLock()
214 defer r.dispatchMu.RUnlock()
215 entries, cached, keyOK, disabled = r.catalogStateLocked()
216 proxyTools = r.connectedProxyToolsLocked()
217 return entries, cached, keyOK, disabled, proxyTools
218 }
219
220 func (r *MCPCapabilityRuntime) catalogStateLocked() (entries []config.PluginEntry, cached map[string][]plugin.CachedTool, keyOK map[string]bool, disabled map[string]bool) {
221 r.mu.RLock()
222 names := make([]string, 0, len(r.servers))
223 for name := range r.servers {
224 names = append(names, name)
225 }
226 sort.Strings(names)
227 entries = make([]config.PluginEntry, 0, len(names))
228 cached = make(map[string][]plugin.CachedTool, len(names))
229 keyOK = make(map[string]bool, len(names))
230 disabled = make(map[string]bool)
231 for _, name := range names {
232 server := r.servers[name]
233 entries = append(entries, runtimePluginEntry(server.entry))
234 if len(server.cached) > 0 {
235 cached[name] = cloneCachedTools(server.cached)
236 keyOK[name] = server.cacheKeyOK
237 }
238 if !server.enabled {
239 disabled[name] = true
240 }
241 }
242 r.mu.RUnlock()
243 if len(cached) == 0 {
244 cached = nil
245 keyOK = nil
246 }
247 if len(disabled) == 0 {
248 disabled = nil
249 }
250 return entries, cached, keyOK, disabled
251 }
252
253 func (r *MCPCapabilityRuntime) configuredServers() []mcpRuntimeServer {
254 if r == nil {
255 return nil
256 }
257 r.mu.RLock()
258 names := make([]string, 0, len(r.servers))
259 for name := range r.servers {
260 names = append(names, name)
261 }
262 sort.Strings(names)
263 out := make([]mcpRuntimeServer, 0, len(names))
264 for _, name := range names {
265 server := r.servers[name]
266 server.spec = cloneMCPSpec(server.spec)
267 server.entry = runtimePluginEntry(server.entry)
268 server.cached = cloneCachedTools(server.cached)
269 out = append(out, server)
270 }
271 r.mu.RUnlock()
272 return out
273 }
274
275 func (r *MCPCapabilityRuntime) enabledSpec(server string) (plugin.Spec, bool) {
276 if r == nil {
277 return plugin.Spec{}, false
278 }
279 r.mu.RLock()
280 configured, ok := r.servers[strings.TrimSpace(server)]
281 r.mu.RUnlock()
282 if !ok || !configured.enabled {
283 return plugin.Spec{}, false
284 }
285 return cloneMCPSpec(configured.spec), true
286 }
287
288 func (r *MCPCapabilityRuntime) serverEnabled(server string) bool {
289 if r == nil {
290 return false
291 }
292 r.mu.RLock()
293 configured, ok := r.servers[strings.TrimSpace(server)]
294 r.mu.RUnlock()
295 return ok && configured.enabled
296 }
297
298 func cachedToolsForSpec(spec plugin.Spec) ([]plugin.CachedTool, bool) {
299 cached, keyOK := capability.LoadCachedToolsForSpecs([]plugin.Spec{spec})
300 return cloneCachedTools(cached[spec.Name]), keyOK[spec.Name]
301 }
302
303 func runtimePluginEntry(entry config.PluginEntry) config.PluginEntry {
304 out := config.PluginEntry{Name: strings.TrimSpace(entry.Name), Source: entry.Source}
305 if entry.AutoStart != nil {
306 value := *entry.AutoStart
307 out.AutoStart = &value
308 }
309 return out
310 }
311
312 func cloneCachedTools(in []plugin.CachedTool) []plugin.CachedTool {
313 if len(in) == 0 {
314 return nil
315 }
316 out := make([]plugin.CachedTool, len(in))
317 copy(out, in)
318 for i := range out {
319 out[i].Schema = append(json.RawMessage(nil), in[i].Schema...)
320 }
321 return out
322 }
323
324 func cloneMCPSpec(in plugin.Spec) plugin.Spec {
325 out := in
326 out.Args = append([]string(nil), in.Args...)
327 out.LaunchArgs = append([]string(nil), in.LaunchArgs...)
328 out.LauncherIdentityArgs = append([]string(nil), in.LauncherIdentityArgs...)
329 out.Env = cloneStringMap(in.Env)
330 out.Headers = cloneStringMap(in.Headers)
331 if in.ToolTimeouts != nil {
332 out.ToolTimeouts = make(map[string]time.Duration, len(in.ToolTimeouts))
333 for name, timeout := range in.ToolTimeouts {
334 out.ToolTimeouts[name] = timeout
335 }
336 }
337 return out
338 }
339
340 func cloneStringMap(in map[string]string) map[string]string {
341 if in == nil {
342 return nil
343 }
344 out := make(map[string]string, len(in))
345 for key, value := range in {
346 out[key] = value
347 }
348 return out
349 }
350
351 // NewFrontend returns a per-agent use_capability instance. ledger/audit may be
352 // nil for ordinary sub-agents that do not run Delivery capability gates.
353 func (r *MCPCapabilityRuntime) NewFrontend(ledger *capability.Ledger, audit *capability.Audit) *UseCapabilityTool {
354 if r == nil {
355 return NewUseCapabilityTool(context.Background(), nil, nil, nil, ledger, audit, nil)
356 }
357 return &UseCapabilityTool{
358 host: r.host,
359 lifeCtx: r.lifeCtx,
360 runtime: r,
361 registry: r.registry,
362 ledger: ledger,
363 audit: audit,
364 catalog: r.catalog,
365 state: r.state,
366 }
367 }
368
369 // ConnectedProxyTools returns live tool metadata for servers connected through
370 // any frontend on this runtime, keyed by server name.
371 func (r *MCPCapabilityRuntime) ConnectedProxyTools() map[string][]plugin.CachedTool {
372 if r == nil || r.state == nil {
373 return nil
374 }
375 r.dispatchMu.RLock()
376 defer r.dispatchMu.RUnlock()
377 return r.connectedProxyToolsLocked()
378 }
379
380 func (r *MCPCapabilityRuntime) connectedProxyToolsLocked() map[string][]plugin.CachedTool {
381 live := r.state.snapshotLiveTools()
382 if len(live) == 0 {
383 return nil
384 }
385 r.mu.RLock()
386 for name := range live {
387 server, ok := r.servers[name]
388 if !ok || !server.enabled {
389 delete(live, name)
390 }
391 }
392 r.mu.RUnlock()
393 if len(live) == 0 {
394 return nil
395 }
396 return live
397 }
398
399 // UseCapabilityTool is the stable MCP capability proxy for Delivery, the
400 // two-model Planner, and task/fleet sub-agents. It lists, inspects, calls, or
401 // declines catalog capabilities without adding dynamic MCP tools to the
402 // provider-visible registry — subsequent calls keep using this stable schema.
403 // Multiple frontends may share one MCPCapabilityRuntime (Host + connection
404 // state) while keeping independent ledger/audit.
405 type UseCapabilityTool struct {
406 host *plugin.Host
407 // lifeCtx is the session-scoped context that owns on-demand MCP child
408 // processes (mirrors lazySpawn.ctx): a proxied server must outlive the tool
409 // call that started it and die with the session, not with a resolve-phase
410 // timeout. nil falls back to context.Background() for direct/test use.
411 lifeCtx context.Context
412 // specs are the boot-converted plugin specs (env expansion, workspace
413 // overrides and timeouts). The proxy never rebuilds
414 // specs from raw config entries — that would fork the conversion logic.
415 specs []plugin.Spec
416 runtime *MCPCapabilityRuntime
417 registry *tool.Registry // live registry for already-exposed MCP tools
418 ledger *capability.Ledger
419 audit *capability.Audit
420 catalog func() capability.Catalog
421 // state is session-shared connection observation when built via
422 // MCPCapabilityRuntime; nil falls back to a private map for tests.
423 state *mcpProxySharedState
424 }
425
426 // runtimeBoundMCPTool keeps the provider-visible MCP adapter unchanged while
427 // binding execution to the current controller runtime. The underlying Host may
428 // be shared by sibling tabs, so a server name alone must never authorize reuse.
429 type runtimeBoundMCPTool struct {
430 proxy *UseCapabilityTool
431 target tool.Tool
432 server string
433 authorized bool
434 }
435
436 func (b *runtimeBoundMCPTool) Name() string { return b.target.Name() }
437 func (b *runtimeBoundMCPTool) Description() string { return b.target.Description() }
438 func (b *runtimeBoundMCPTool) Schema() json.RawMessage { return b.target.Schema() }
439 func (b *runtimeBoundMCPTool) ReadOnly() bool { return b.target.ReadOnly() }
440 func (b *runtimeBoundMCPTool) MCPServerAuthorized() bool { return b.authorized }
441 func (b *runtimeBoundMCPTool) MCPServerName() string { return b.server }
442 func (b *runtimeBoundMCPTool) MCPRawToolName() string { return mcpRawToolName(b.target) }
443 func (b *runtimeBoundMCPTool) MCPDestructiveHint() bool { return mcpDestructiveHint(b.target) }
444 func (b *runtimeBoundMCPTool) MCPVisibleToolName() string {
445 if meta, ok := b.target.(tool.MCPVisibleMetadata); ok {
446 return meta.MCPVisibleToolName()
447 }
448 return b.MCPRawToolName()
449 }
450 func (b *runtimeBoundMCPTool) MCPPackageName() string {
451 if meta, ok := b.target.(tool.MCPPackageMetadata); ok {
452 return meta.MCPPackageName()
453 }
454 return ""
455 }
456
457 func (b *runtimeBoundMCPTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
458 var out string
459 err := b.proxy.withRuntimeBoundMCP(ctx, b.server, b.target, func() error {
460 var execErr error
461 out, execErr = b.target.Execute(ctx, args)
462 return execErr
463 })
464 return out, err
465 }
466
467 func (b *runtimeBoundMCPTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
468 var out string
469 var images []string
470 err := b.proxy.withRuntimeBoundMCP(ctx, b.server, b.target, func() error {
471 if imageTool, ok := b.target.(tool.ImageTool); ok {
472 var execErr error
473 out, images, execErr = imageTool.ExecuteWithImages(ctx, args)
474 return execErr
475 }
476 var execErr error
477 out, execErr = b.target.Execute(ctx, args)
478 return execErr
479 })
480 return out, images, err
481 }
482
483 func mcpRawToolName(target tool.Tool) string {
484 if meta, ok := target.(tool.MCPMetadata); ok {
485 return meta.MCPRawToolName()
486 }
487 return ""
488 }
489
490 // NewUseCapabilityTool builds a standalone capability proxy (tests and simple
491 // boots). Prefer MCPCapabilityRuntime.NewFrontend when multiple agents share
492 // one session Host.
493 func NewUseCapabilityTool(lifeCtx context.Context, host *plugin.Host, specs []plugin.Spec, reg *tool.Registry, ledger *capability.Ledger, audit *capability.Audit, catalog func() capability.Catalog) *UseCapabilityTool {
494 return &UseCapabilityTool{
495 host: host,
496 lifeCtx: lifeCtx,
497 specs: append([]plugin.Spec(nil), specs...),
498 registry: reg,
499 ledger: ledger,
500 audit: audit,
501 catalog: catalog,
502 state: &mcpProxySharedState{connected: map[string]bool{}},
503 }
504 }
505
506 // CloneForAgent returns a new frontend sharing Host/specs/connection state but
507 // with independent ledger and audit (nil unless provided).
508 func (t *UseCapabilityTool) CloneForAgent(ledger *capability.Ledger, audit *capability.Audit) *UseCapabilityTool {
509 if t == nil {
510 return nil
511 }
512 state := t.state
513 if state == nil {
514 state = &mcpProxySharedState{connected: map[string]bool{}}
515 }
516 return &UseCapabilityTool{
517 host: t.host,
518 lifeCtx: t.lifeCtx,
519 specs: t.specs,
520 runtime: t.runtime,
521 registry: t.registry,
522 ledger: ledger,
523 audit: audit,
524 catalog: t.catalog,
525 state: state,
526 }
527 }
528
529 func (*UseCapabilityTool) Name() string { return "use_capability" }
530
531 func (*UseCapabilityTool) Description() string {
532 return "Stable capability proxy: list configured MCP servers without starting them, inspect Skill/MCP metadata, call MCP tools (including auto_start=false servers) without changing the provider tool schema, or decline a prefer capability with a non-empty reason. Skills still use run_skill; this tool only proxies MCP. The Planner leaves destructive MCP for the Executor; ordinary writer-capable agents trust installed or project-authorized MCP subject to explicit deny and mutation guards."
533 }
534
535 func (*UseCapabilityTool) ReadOnly() bool { return true }
536
537 func (*UseCapabilityTool) Schema() json.RawMessage {
538 // Stable schema — must not change across turns or when MCP connects.
539 // capability_id is optional only for action=list; inspect/call/decline still
540 // require it at resolve time. One intentional prefix upgrade; thereafter
541 // install/connect churn does not change this schema.
542 return json.RawMessage(`{
543 "type":"object",
544 "properties":{
545 "action":{"type":"string","description":"list | inspect | call | decline"},
546 "capability_id":{"type":"string","description":"Capability id such as skill:review, mcp-server:github, or mcp-tool:github/search_issues. Not required for action=list."},
547 "arguments":{"type":"object","description":"Raw MCP tool arguments for action=call"},
548 "reason":{"type":"string","description":"Required non-empty reason when action=decline"}
549 },
550 "required":["action"]
551 }`)
552 }
553
554 // ResolveCall implements tool.CallResolver so the agent can run permission,
555 // hooks, and evidence against the real MCP target before execution.
556 func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
557 var p struct {
558 Action string `json:"action"`
559 CapabilityID string `json:"capability_id"`
560 Arguments json.RawMessage `json:"arguments"`
561 Reason string `json:"reason"`
562 }
563 if err := json.Unmarshal(args, &p); err != nil {
564 return tool.ResolvedCall{}, fmt.Errorf("invalid args: %w", err)
565 }
566 action := strings.ToLower(strings.TrimSpace(p.Action))
567 id := strings.TrimSpace(p.CapabilityID)
568 base := tool.ResolvedCall{
569 DisplayName: "use_capability",
570 ProxyAction: action,
571 CapabilityID: id,
572 Args: p.Arguments,
573 }
574 switch action {
575 case "list":
576 out, err := t.listServers()
577 if err != nil {
578 if t.audit != nil {
579 t.audit.RecordMCPProxy(true, false, true)
580 }
581 return tool.ResolvedCall{}, err
582 }
583 if t.audit != nil {
584 t.audit.RecordMCPProxy(true, false, false)
585 }
586 base.SkipExecute = true
587 base.Result = out
588 base.ReadOnly = true
589 return base, nil
590 case "inspect":
591 if id == "" {
592 return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=inspect")
593 }
594 out, err := t.inspect(ctx, id)
595 if err != nil {
596 if t.audit != nil {
597 t.audit.RecordMCPProxy(true, false, true)
598 }
599 return tool.ResolvedCall{}, err
600 }
601 if t.audit != nil {
602 t.audit.RecordMCPProxy(true, false, false)
603 }
604 base.SkipExecute = true
605 base.Result = out
606 base.ReadOnly = true
607 return base, nil
608 case "decline":
609 if id == "" {
610 return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=decline")
611 }
612 reason := strings.TrimSpace(p.Reason)
613 if reason == "" {
614 return tool.ResolvedCall{}, fmt.Errorf("reason is required for action=decline")
615 }
616 // Decline must not skip require. The mutation itself is delayed until the
617 // agent has applied its post-resolution host boundary.
618 if t.ledger != nil {
619 if e, ok := t.ledger.Get(id); ok && e.Policy == capability.AutoUseRequire {
620 return tool.ResolvedCall{}, fmt.Errorf("cannot decline a require capability %q", id)
621 }
622 }
623 base.SkipExecute = true
624 base.Result = fmt.Sprintf("declined capability %s: %s", id, reason)
625 base.ReadOnly = true
626 base.Commit = func() error {
627 if t.ledger != nil {
628 if err := t.ledger.MarkDeclined(id, reason); err != nil {
629 return err
630 }
631 }
632 if t.audit != nil {
633 t.audit.RecordDecline()
634 }
635 return nil
636 }
637 return base, nil
638 case "call":
639 if id == "" {
640 return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=call")
641 }
642 return t.resolveCall(ctx, id, p.Arguments, base)
643 default:
644 return tool.ResolvedCall{}, fmt.Errorf("unknown action %q; use list, inspect, call, or decline", p.Action)
645 }
646 }
647
648 func (t *UseCapabilityTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
649 resolved, err := t.ResolveCall(ctx, args)
650 if err != nil {
651 return "", err
652 }
653 if resolved.SkipExecute {
654 if resolved.Commit != nil {
655 if err := resolved.Commit(); err != nil {
656 return "", err
657 }
658 }
659 if resolved.ProxyAction == "call" && !resolved.Unavailable {
660 if t.ledger != nil {
661 t.ledger.MarkSucceeded(resolved.CapabilityID)
662 }
663 if t.audit != nil {
664 t.audit.RecordMCPProxy(false, true, false)
665 }
666 }
667 return resolved.Result, nil
668 }
669 if resolved.Unavailable {
670 if t.ledger != nil {
671 t.ledger.MarkUnavailable(resolved.CapabilityID, resolved.UnavailableReason)
672 }
673 return "", fmt.Errorf("capability unavailable: %s", resolved.UnavailableReason)
674 }
675 if resolved.Target == nil {
676 return "", fmt.Errorf("no target tool resolved for %s", resolved.CapabilityID)
677 }
678 if t.ledger != nil {
679 t.ledger.MarkInvoked(resolved.CapabilityID)
680 }
681 if t.audit != nil {
682 t.audit.RecordMCPProxy(false, true, false)
683 }
684 out, err := resolved.Target.Execute(ctx, resolved.Args)
685 if err != nil {
686 if t.ledger != nil {
687 t.ledger.MarkFailed(resolved.CapabilityID, err.Error())
688 }
689 if t.audit != nil {
690 t.audit.RecordMCPProxy(false, true, true)
691 }
692 return out, err
693 }
694 if t.ledger != nil {
695 t.ledger.MarkSucceeded(resolved.CapabilityID)
696 }
697 return out, nil
698 }
699
700 // listServerInfo is one configured MCP server entry returned by action=list.
701 // It never starts a server or opens a network connection.
702 type listServerInfo struct {
703 Name string `json:"name"`
704 CapabilityID string `json:"capability_id"`
705 Status string `json:"status"`
706 Authorized bool `json:"authorized"`
707 Connected bool `json:"connected"`
708 }
709
710 // listServers returns sorted configured MCP server names, status, and
711 // capability IDs without starting servers. Used by Planner discovery when no
712 // specific capability route was provided.
713 func (t *UseCapabilityTool) listServers() (string, error) {
714 configured := t.configuredServers()
715 list := make([]listServerInfo, 0, len(configured))
716 for _, server := range configured {
717 spec := server.spec
718 name := strings.TrimSpace(spec.Name)
719 if name == "" {
720 continue
721 }
722 // Apply stored project grants without process/network side effects so
723 // list status matches resolve/execute authorization.
724 resolved := plugin.ResolveStoredAuthorization(context.Background(), spec)
725 connected := server.enabled && resolved.ServerAuthorized() && t.host != nil && t.host.HasClientForSpec(resolved)
726 status := "configured"
727 if !server.enabled {
728 status = "disabled"
729 } else if connected {
730 status = "ready"
731 } else if t.host != nil {
732 for _, f := range t.host.Failures() {
733 if f.Name == name && strings.TrimSpace(f.Error) != "" {
734 status = "failed"
735 break
736 }
737 }
738 }
739 list = append(list, listServerInfo{
740 Name: name,
741 CapabilityID: "mcp-server:" + name,
742 Status: status,
743 Authorized: resolved.ServerAuthorized(),
744 Connected: connected,
745 })
746 }
747 b, err := json.MarshalIndent(map[string]any{
748 "servers": list,
749 "note": "list does not start MCP servers. Call action=call on mcp-server:<name> to connect after authorization, or mcp-tool:<server>/<tool> for a concrete tool.",
750 }, "", " ")
751 if err != nil {
752 return "", err
753 }
754 return string(b), nil
755 }
756
757 func (t *UseCapabilityTool) inspect(ctx context.Context, id string) (string, error) {
758 cat := t.currentCatalog()
759 if e, ok := cat.Lookup(id); ok {
760 b, _ := json.MarshalIndent(map[string]any{
761 "id": e.ID,
762 "kind": e.Kind,
763 "name": e.Name,
764 "description": e.Description,
765 "status": e.Status,
766 "read_only": e.ReadOnly,
767 "auto_use": e.AutoUse,
768 "requires": e.Requires,
769 "profiles": e.Profiles,
770 "tool_name": e.ToolName,
771 "auto_start": e.AutoStart,
772 }, "", " ")
773 // For MCP entries, list tools without side effects: live tools when the
774 // server is already connected, cached schema otherwise. Inspect runs
775 // during call resolution — before permission and hook gates — so it must
776 // never start a subprocess or open a network connection.
777 if e.Kind == capability.KindMCPServer || e.Kind == capability.KindMCPTool {
778 server := e.Source
779 if server == "" {
780 server = e.ConnectName
781 }
782 toolFilter := ""
783 if e.Kind == capability.KindMCPTool {
784 parsedServer, raw, err := parseMCPCapabilityID(e.ID)
785 if err != nil {
786 return string(b), nil
787 }
788 if server == "" {
789 server = parsedServer
790 } else if parsedServer != server {
791 return string(b), nil
792 }
793 toolFilter = raw
794 }
795 if server != "" {
796 if !t.serverEnabled(server) {
797 return string(b) + "\n\nServer is disabled in this session.", nil
798 }
799 if t.host != nil && t.host.HasClient(server) {
800 // serverTools refreshes the snapshot too: inspecting a
801 // server another tab connected restores tool routing here.
802 tools, err := t.serverTools(ctx, server)
803 if err != nil {
804 return string(b) + "\n\nTool listing failed: " + err.Error(), nil
805 }
806 return string(b) + "\n\nTools:\n" + inspectToolListJSON(server, filterInspectTools(tools, toolFilter)), nil
807 }
808 if spec, ok := t.specFor(server); ok {
809 if cs, ok := plugin.LoadCachedSchemaForSpec(spec); ok && len(cs.Tools) > 0 {
810 var list []inspectToolInfo
811 for _, ct := range cs.Tools {
812 if toolFilter != "" && ct.Name != toolFilter {
813 continue
814 }
815 list = append(list, inspectToolInfo{
816 ID: "mcp-tool:" + server + "/" + ct.Name,
817 Name: plugin.ModelToolName(server, ct.Name),
818 Description: ct.Description,
819 ReadOnly: ct.ReadOnly,
820 Schema: ct.Schema,
821 })
822 }
823 extra, _ := json.MarshalIndent(list, "", " ")
824 return string(b) + "\n\nTools (from cached schema; server not started):\n" + string(extra), nil
825 }
826 return string(b) + "\n\nServer not connected and no cached tool schema; call use_capability(action=\"call\", capability_id=\"mcp-server:" + server + "\") to connect (after approval) and list its tools.", nil
827 }
828 }
829 }
830 return string(b), nil
831 }
832 return "", fmt.Errorf("unknown capability_id %q", id)
833 }
834
835 // filterInspectTools narrows concrete mcp-tool inspection to that exact tool.
836 // Server inspection intentionally keeps the full directory. This prevents a
837 // restricted sub-agent allowed one tool from discovering sibling tool schemas
838 // through action=inspect on its allowed capability ID.
839 func filterInspectTools(tools []tool.Tool, raw string) []tool.Tool {
840 if raw == "" {
841 return tools
842 }
843 filtered := make([]tool.Tool, 0, 1)
844 for _, tl := range tools {
845 if m, ok := tl.(tool.MCPMetadata); ok && m.MCPRawToolName() == raw {
846 filtered = append(filtered, tl)
847 break
848 }
849 }
850 return filtered
851 }
852
853 type inspectToolInfo struct {
854 ID string `json:"id"`
855 Name string `json:"name"`
856 Description string `json:"description"`
857 ReadOnly bool `json:"read_only"`
858 Schema json.RawMessage `json:"input_schema,omitempty"`
859 }
860
861 // inspectToolListJSON renders a server's live tools as the capability-id
862 // directory shared by inspect and the first-discovery connect result.
863 func inspectToolListJSON(server string, tools []tool.Tool) string {
864 var list []inspectToolInfo
865 for _, tl := range tools {
866 raw := ""
867 if m, ok := tl.(tool.MCPMetadata); ok {
868 raw = m.MCPRawToolName()
869 }
870 list = append(list, inspectToolInfo{
871 ID: "mcp-tool:" + server + "/" + raw,
872 Name: tl.Name(),
873 Description: tl.Description(),
874 ReadOnly: tl.ReadOnly(),
875 Schema: tl.Schema(),
876 })
877 }
878 extra, _ := json.MarshalIndent(list, "", " ")
879 return string(extra)
880 }
881
882 func (t *UseCapabilityTool) resolveCall(ctx context.Context, id string, args json.RawMessage, base tool.ResolvedCall) (tool.ResolvedCall, error) {
883 // Server-level call is the first-discovery path for servers with no
884 // schema cache: it resolves to a gated connect-and-list target so the
885 // model can learn tool names without inspect ever starting a process.
886 if server, ok := parseMCPServerCapabilityID(id); ok {
887 return t.resolveServerConnect(ctx, server, base)
888 }
889 server, raw, err := parseMCPCapabilityID(id)
890 if err != nil {
891 // Skills must use run_skill.
892 if strings.HasPrefix(id, "skill:") {
893 return tool.ResolvedCall{}, fmt.Errorf("call only proxies MCP tools; use run_skill for %s", id)
894 }
895 return tool.ResolvedCall{}, err
896 }
897 if !t.serverEnabled(server) {
898 return t.resolveUnavailable(base, id, plugin.ModelToolName(server, raw), fmt.Sprintf("MCP server %q is disabled in this session", server)), nil
899 }
900 var runtimeSpec plugin.Spec
901 if t.runtime != nil {
902 spec, unlock, lockErr := t.lockAuthorizedRuntimeServer(ctx, server)
903 if lockErr != nil {
904 return t.resolveUnavailable(base, id, plugin.ModelToolName(server, raw), lockErr.Error()), nil
905 }
906 defer unlock()
907 runtimeSpec = spec
908 }
909 // Prefer already-exposed registry tool (auto-started MCP). The model name
910 // MUST come from the plugin layer's canonical constructor: it appends a
911 // collision hash for sanitised raw names, and permission/hook rules are
912 // written against that executed name — a proxy-local normalization would
913 // let them silently miss.
914 modelName := plugin.ModelToolName(server, raw)
915 if t.registry != nil {
916 if tl, ok := t.registry.Get(modelName); ok {
917 if t.runtime != nil && !plugin.MCPToolMatchesSpec(tl, runtimeSpec) {
918 return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("connected MCP server %q identity does not match the current runtime configuration", server)), nil
919 }
920 base.TargetName = modelName
921 base.Target = t.bindRuntimeMCP(runtimeSpec, tl)
922 base.ReadOnly = tl.ReadOnly()
923 if len(args) == 0 {
924 base.Args = json.RawMessage(`{}`)
925 } else {
926 base.Args = args
927 }
928 return base, nil
929 }
930 }
931 // Server already connected (auto-started, a previous proxy call, or a
932 // sibling tab sharing this host): resolving against live tools is
933 // side-effect-free. serverTools also refreshes the catalog snapshot so a
934 // cross-tab connect still yields routable mcp-tool entries here.
935 if t.host != nil && t.host.HasClient(server) {
936 var tools []tool.Tool
937 if t.runtime != nil {
938 tools, err = t.serverToolsForSpec(ctx, server, runtimeSpec)
939 } else {
940 tools, err = t.serverTools(ctx, server)
941 }
942 if err != nil {
943 return t.resolveUnavailable(base, id, modelName, err.Error()), nil
944 }
945 target := findMCPTool(tools, raw, modelName)
946 if target == nil {
947 return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("MCP tool %q not found on server %q", raw, server)), nil
948 }
949 base.Target = t.bindRuntimeMCP(runtimeSpec, target)
950 base.TargetName = target.Name()
951 base.ReadOnly = target.ReadOnly()
952 if len(args) == 0 {
953 base.Args = json.RawMessage(`{}`)
954 } else {
955 base.Args = args
956 }
957 return base, nil
958 }
959 // Unconnected server: resolution must stay pure — no subprocess, no network.
960 // Return a deferred target that connects in Execute, after the permission
961 // gate and PreToolUse hooks have approved the real target name/arguments.
962 spec := runtimeSpec
963 if t.runtime == nil {
964 var ok bool
965 spec, ok = t.specFor(server)
966 if !ok {
967 return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("MCP server %q is not configured", server)), nil
968 }
969 spec = plugin.ResolveStoredAuthorization(ctx, spec)
970 }
971 destructive := false
972 if t.catalog != nil {
973 if entry, found := t.catalog().Lookup(id); found {
974 destructive = entry.Destructive
975 }
976 }
977 readOnly := false
978 if cached, found := plugin.CachedToolSafetyForSpec(spec, raw); found {
979 destructive = destructive || cached.Destructive
980 readOnly = cached.ReadOnly
981 }
982 lazy := &onDemandMCPTool{proxy: t, spec: spec, server: server, raw: raw, modelName: modelName, destructive: destructive}
983 lazy.readOnly = readOnly
984 base.Target = lazy
985 base.TargetName = modelName
986 // Cached server hints control ordinary approval. Strict read-only execution
987 // additionally requires server authorization and live read-only metadata.
988 base.ReadOnly = lazy.ReadOnly()
989 if len(args) == 0 {
990 base.Args = json.RawMessage(`{}`)
991 } else {
992 base.Args = args
993 }
994 return base, nil
995 }
996
997 // resolveUnavailable fills the host-proven unavailable shape shared by the
998 // side-effect-free resolution failures (missing config, unknown tool).
999 func (t *UseCapabilityTool) resolveUnavailable(base tool.ResolvedCall, id, modelName, reason string) tool.ResolvedCall {
1000 base.Unavailable = true
1001 base.UnavailableReason = reason
1002 base.SkipExecute = true
1003 base.Result = "capability unavailable: " + reason
1004 base.TargetName = modelName
1005 base.ReadOnly = false
1006 base.Commit = func() error {
1007 if t.ledger != nil {
1008 t.ledger.MarkUnavailable(id, reason)
1009 }
1010 if t.audit != nil {
1011 t.audit.RecordMCPProxy(false, true, true)
1012 }
1013 return nil
1014 }
1015 return base
1016 }
1017
1018 // findMCPTool matches a server's tool list by raw MCP name or by the
1019 // canonical namespaced model-visible name (plugin.ModelToolName).
1020 func findMCPTool(tools []tool.Tool, raw, modelName string) tool.Tool {
1021 for _, tl := range tools {
1022 if m, ok := tl.(tool.MCPMetadata); ok && m.MCPRawToolName() == raw {
1023 return tl
1024 }
1025 if tl.Name() == modelName {
1026 return tl
1027 }
1028 }
1029 return nil
1030 }
1031
1032 // onDemandMCPTool defers MCP server startup to Execute so permission and hook
1033 // gates always run before any subprocess or network side effect. Before the live
1034 // handshake it remains write-capable until the resolved MCP tool is classified.
1035 type onDemandMCPTool struct {
1036 proxy *UseCapabilityTool
1037 spec plugin.Spec
1038 server string
1039 raw string
1040 modelName string
1041 // destructive comes from the schema cache when available. A live promotion
1042 // is detected in Execute so a retry re-enters the current Plan/read-only
1043 // execution boundary.
1044 destructive bool
1045 readOnly bool
1046 }
1047
1048 func (o *onDemandMCPTool) Name() string { return o.modelName }
1049
1050 func (o *onDemandMCPTool) Description() string {
1051 return "on-demand MCP tool " + o.server + "/" + o.raw + " (connects when first used)"
1052 }
1053
1054 func (o *onDemandMCPTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
1055
1056 func (o *onDemandMCPTool) ReadOnly() bool {
1057 return o.readOnly
1058 }
1059
1060 func (o *onDemandMCPTool) ReadOnlyExecutionHostMutation() bool { return true }
1061
1062 func (o *onDemandMCPTool) MCPServerAuthorized() bool {
1063 // Spec.Authorized is the single runtime authorization result. Boot/install
1064 // and ResolveStoredAuthorization set it; this path never invents trust.
1065 return o.spec.ServerAuthorized()
1066 }
1067
1068 func (o *onDemandMCPTool) ReadOnlyExecutionBlockReason() string {
1069 return "connect this MCP capability from a parent session first"
1070 }
1071
1072 // MCPServerName/MCPRawToolName expose the deferred target for audit and
1073 // diagnostics (tool.MCPMetadata).
1074 func (o *onDemandMCPTool) MCPServerName() string { return o.server }
1075 func (o *onDemandMCPTool) MCPRawToolName() string { return o.raw }
1076 func (o *onDemandMCPTool) MCPDestructiveHint() bool {
1077 return o.destructive
1078 }
1079
1080 func (o *onDemandMCPTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
1081 text, _, err := o.executeWithImages(ctx, args)
1082 return text, err
1083 }
1084
1085 // ExecuteWithImages preserves structured MCP image results on the first call,
1086 // when the deferred target must connect the server before dispatch. Keeping the
1087 // resolution and safety checks in executeWithImages ensures text-only and image
1088 // callers share the same authorization and runtime-identity boundary.
1089 func (o *onDemandMCPTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
1090 return o.executeWithImages(ctx, args)
1091 }
1092
1093 func (o *onDemandMCPTool) executeWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
1094 // Final runtime-bound authorization and identity check before any
1095 // process/network start. The read lock also linearizes this dispatch against
1096 // disable, uninstall, and same-name hot replacement.
1097 spec, unlock, err := o.proxy.lockAuthorizedRuntimeServer(ctx, o.server)
1098 if err != nil {
1099 msg := err.Error()
1100 if o.proxy.ledger != nil {
1101 o.proxy.ledger.MarkUnavailable("mcp-tool:"+o.server+"/"+o.raw, msg)
1102 }
1103 return "", nil, err
1104 }
1105 defer unlock()
1106 if !plugin.MCPRuntimeSpecMatches(spec, o.spec) {
1107 return "", nil, fmt.Errorf("MCP server %q runtime identity changed after resolution; retry so Reasonix can bind the current configuration", o.server)
1108 }
1109 tools, err := o.proxy.ensureServerToolsForSpec(ctx, o.server, spec)
1110 if err != nil {
1111 // Audit for the call path is recorded once by the agent loop
1112 // (noteCapabilityInvocation); only the ledger outcome lands here.
1113 if o.proxy.ledger != nil {
1114 o.proxy.ledger.MarkUnavailable("mcp-tool:"+o.server+"/"+o.raw, err.Error())
1115 }
1116 return "", nil, err
1117 }
1118 target := findMCPTool(tools, o.raw, o.modelName)
1119 if target == nil {
1120 msg := fmt.Sprintf("MCP tool %q not found on server %q", o.raw, o.server)
1121 if o.proxy.ledger != nil {
1122 o.proxy.ledger.MarkUnavailable("mcp-tool:"+o.server+"/"+o.raw, msg)
1123 }
1124 return "", nil, fmt.Errorf("%s", msg)
1125 }
1126 if !plugin.MCPToolMatchesSpec(target, spec) {
1127 return "", nil, fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration; reconnect this server before retrying", o.server)
1128 }
1129 if _, err := plugin.ReconcileCachedToolSafety(o.server, o.raw, plugin.CachedToolSafety{
1130 ReadOnly: o.readOnly,
1131 Destructive: o.destructive,
1132 }, target); err != nil {
1133 return "", nil, err
1134 }
1135 // Planner non-destructive lane and reader lane: re-check live metadata
1136 // before tools/call even when Reconcile did not see a cache promotion.
1137 if tool.HasNonDestructiveMCPExecutionIntent(ctx) {
1138 if !mcpServerAuthorized(target) || mcpDestructiveHint(target) {
1139 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", o.server, o.raw)
1140 }
1141 }
1142 if imageTool, ok := target.(tool.ImageTool); ok {
1143 return imageTool.ExecuteWithImages(ctx, args)
1144 }
1145 text, err := target.Execute(ctx, args)
1146 return text, nil, err
1147 }
1148
1149 func (t *UseCapabilityTool) ensureServerToolsForSpec(ctx context.Context, server string, spec plugin.Spec) ([]tool.Tool, error) {
1150 // Reuse shared host if already connected (including auto-started).
1151 if t.host.HasClient(server) {
1152 return t.serverToolsForSpec(ctx, server, spec)
1153 }
1154 // On-demand connect: the child and handshake belong to the session. This
1155 // tool call waits briefly, but a slow healthy server continues in the
1156 // background instead of being killed and restarted on every retry. Tools
1157 // stay off the main provider-visible registry.
1158 life := t.lifeCtx
1159 if life == nil {
1160 life = context.Background()
1161 }
1162 result := t.host.EnsureConnectedInBackground(life, spec)
1163 waitBudget := plugin.DefaultStartupWaitBudget()
1164 timer := time.NewTimer(waitBudget)
1165 defer timer.Stop()
1166 var tools []tool.Tool
1167 var err error
1168 select {
1169 case connected := <-result:
1170 tools, err = connected.Tools, connected.Err
1171 case <-ctx.Done():
1172 return nil, ctx.Err()
1173 case <-timer.C:
1174 return nil, fmt.Errorf("MCP server %q is still initializing after %s; startup continues in background (limit %s) — retry on a later turn",
1175 server, waitBudget, spec.ResolvedStartupTimeout())
1176 }
1177 if err != nil {
1178 if plugin.IsServerAlreadyConnected(err) {
1179 return t.serverToolsForSpec(ctx, server, spec)
1180 }
1181 t.host.RecordFailure(spec, err)
1182 return nil, fmt.Errorf("connect %q: %w", server, err)
1183 }
1184 t.ensureState().markConnected(server)
1185 // Intentionally do NOT add tools to t.registry — provider schema stays stable.
1186 _ = tools
1187 return t.serverToolsForSpec(ctx, server, spec)
1188 }
1189
1190 // serverTools fetches the live tools for a connected server and refreshes the
1191 // shared catalog snapshot so mcp-tool entries stay routable once the server
1192 // is StatusReady (its tools are absent from the provider-visible registry).
1193 func (t *UseCapabilityTool) serverTools(ctx context.Context, server string) ([]tool.Tool, error) {
1194 spec, unlock, err := t.lockAuthorizedRuntimeServer(ctx, server)
1195 if err != nil {
1196 return nil, err
1197 }
1198 defer unlock()
1199 return t.serverToolsForSpec(ctx, server, spec)
1200 }
1201
1202 func (t *UseCapabilityTool) serverToolsForSpec(ctx context.Context, server string, spec plugin.Spec) ([]tool.Tool, error) {
1203 tools, err := t.host.ToolsForSpec(ctx, spec)
1204 if err != nil {
1205 return nil, err
1206 }
1207 snap := make([]plugin.CachedTool, 0, len(tools))
1208 for _, tl := range tools {
1209 m, ok := tl.(tool.MCPMetadata)
1210 if !ok || m.MCPRawToolName() == "" {
1211 continue
1212 }
1213 snap = append(snap, plugin.CachedTool{
1214 Name: m.MCPRawToolName(),
1215 Description: tl.Description(),
1216 Schema: tl.Schema(),
1217 ReadOnly: tl.ReadOnly(),
1218 Destructive: mcpDestructiveHint(tl),
1219 })
1220 }
1221 t.ensureState().setLiveTools(server, snap)
1222 return tools, nil
1223 }
1224
1225 // ConnectedProxyTools returns raw tool metadata for servers connected through
1226 // any frontend sharing this proxy state, keyed by server name. Catalog builders
1227 // consume it so concrete mcp-tool capabilities survive an on-demand connect
1228 // without ever touching the provider-visible registry.
1229 func (t *UseCapabilityTool) ConnectedProxyTools() map[string][]plugin.CachedTool {
1230 if t == nil {
1231 return nil
1232 }
1233 return t.ensureState().snapshotLiveTools()
1234 }
1235
1236 func (t *UseCapabilityTool) ensureState() *mcpProxySharedState {
1237 if t.state == nil {
1238 t.state = &mcpProxySharedState{connected: map[string]bool{}}
1239 }
1240 return t.state
1241 }
1242
1243 func (s *mcpProxySharedState) markConnected(server string) {
1244 if s == nil {
1245 return
1246 }
1247 s.mu.Lock()
1248 defer s.mu.Unlock()
1249 if s.connected == nil {
1250 s.connected = map[string]bool{}
1251 }
1252 s.connected[server] = true
1253 }
1254
1255 func (s *mcpProxySharedState) setLiveTools(server string, snap []plugin.CachedTool) {
1256 if s == nil {
1257 return
1258 }
1259 s.mu.Lock()
1260 defer s.mu.Unlock()
1261 if s.liveTools == nil {
1262 s.liveTools = map[string][]plugin.CachedTool{}
1263 }
1264 s.liveTools[server] = snap
1265 if s.connected == nil {
1266 s.connected = map[string]bool{}
1267 }
1268 s.connected[server] = true
1269 }
1270
1271 func (s *mcpProxySharedState) snapshotLiveTools() map[string][]plugin.CachedTool {
1272 if s == nil {
1273 return nil
1274 }
1275 s.mu.Lock()
1276 defer s.mu.Unlock()
1277 if len(s.liveTools) == 0 {
1278 return nil
1279 }
1280 out := make(map[string][]plugin.CachedTool, len(s.liveTools))
1281 for k, v := range s.liveTools {
1282 out[k] = append([]plugin.CachedTool(nil), v...)
1283 }
1284 return out
1285 }
1286
1287 func (s *mcpProxySharedState) clearServer(server string) {
1288 if s == nil {
1289 return
1290 }
1291 s.mu.Lock()
1292 delete(s.connected, strings.TrimSpace(server))
1293 delete(s.liveTools, strings.TrimSpace(server))
1294 s.mu.Unlock()
1295 }
1296
1297 // specFor looks up the boot-converted spec for server. The proxy deliberately
1298 // holds []plugin.Spec, not raw config entries: env expansion, workspace
1299 // overrides, call timeouts, and read-only tool names all live in the
1300 // shared conversion and must not be re-derived here.
1301 func (t *UseCapabilityTool) specFor(server string) (plugin.Spec, bool) {
1302 if t.runtime != nil {
1303 return t.runtime.enabledSpec(server)
1304 }
1305 for _, s := range t.specs {
1306 if s.Name == server {
1307 return s, true
1308 }
1309 }
1310 return plugin.Spec{}, false
1311 }
1312
1313 // lockAuthorizedRuntimeServer acquires the shared runtime dispatch read lock
1314 // and returns the current enabled, authorized spec. The caller must keep the
1315 // returned lock until the identity-bound Host operation or tools/call has
1316 // crossed its dispatch boundary; lifecycle mutations take the write lock.
1317 func (t *UseCapabilityTool) lockAuthorizedRuntimeServer(ctx context.Context, server string) (plugin.Spec, func(), error) {
1318 if t.runtime == nil {
1319 spec, ok := t.specFor(server)
1320 if !ok {
1321 return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not configured", server)
1322 }
1323 spec = plugin.ResolveStoredAuthorization(ctx, spec)
1324 if !spec.ServerAuthorized() {
1325 return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not authorized; install it or complete project identity approval before connecting", server)
1326 }
1327 return spec, func() {}, nil
1328 }
1329
1330 t.runtime.dispatchMu.RLock()
1331 unlock := t.runtime.dispatchMu.RUnlock
1332 t.runtime.mu.RLock()
1333 configured, ok := t.runtime.servers[strings.TrimSpace(server)]
1334 t.runtime.mu.RUnlock()
1335 if !ok {
1336 unlock()
1337 return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not configured", server)
1338 }
1339 if !configured.enabled {
1340 unlock()
1341 return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is disabled in this session", server)
1342 }
1343 spec := plugin.ResolveStoredAuthorization(ctx, cloneMCPSpec(configured.spec))
1344 if !spec.ServerAuthorized() {
1345 unlock()
1346 return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not authorized; install it or complete project identity approval before connecting", server)
1347 }
1348 return spec, unlock, nil
1349 }
1350
1351 func (t *UseCapabilityTool) bindRuntimeMCP(spec plugin.Spec, target tool.Tool) tool.Tool {
1352 if t.runtime == nil || target == nil {
1353 return target
1354 }
1355 return &runtimeBoundMCPTool{
1356 proxy: t,
1357 target: target,
1358 server: spec.Name,
1359 authorized: spec.ServerAuthorized(),
1360 }
1361 }
1362
1363 func (t *UseCapabilityTool) withRuntimeBoundMCP(ctx context.Context, server string, target tool.Tool, execute func() error) error {
1364 if t.runtime == nil {
1365 return execute()
1366 }
1367 spec, unlock, err := t.lockAuthorizedRuntimeServer(ctx, server)
1368 if err != nil {
1369 return err
1370 }
1371 defer unlock()
1372 if !plugin.MCPToolMatchesSpec(target, spec) {
1373 return fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration; reconnect this server before retrying", server)
1374 }
1375 return execute()
1376 }
1377
1378 func (t *UseCapabilityTool) serverEnabled(server string) bool {
1379 if t.runtime != nil {
1380 return t.runtime.serverEnabled(server)
1381 }
1382 // Standalone proxies predate the authoritative runtime and may resolve
1383 // already-registered MCP tools without carrying a duplicate spec slice.
1384 return true
1385 }
1386
1387 func (t *UseCapabilityTool) configuredServers() []mcpRuntimeServer {
1388 if t.runtime != nil {
1389 return t.runtime.configuredServers()
1390 }
1391 servers := make([]mcpRuntimeServer, 0, len(t.specs))
1392 seen := map[string]bool{}
1393 for _, raw := range t.specs {
1394 spec := cloneMCPSpec(raw)
1395 name := strings.TrimSpace(spec.Name)
1396 if name == "" || seen[name] {
1397 continue
1398 }
1399 seen[name] = true
1400 servers = append(servers, mcpRuntimeServer{
1401 entry: config.PluginEntry{Name: name},
1402 spec: spec,
1403 enabled: true,
1404 })
1405 }
1406 sort.Slice(servers, func(i, j int) bool { return servers[i].spec.Name < servers[j].spec.Name })
1407 return servers
1408 }
1409
1410 func (t *UseCapabilityTool) currentCatalog() capability.Catalog {
1411 if t.catalog != nil {
1412 return t.catalog()
1413 }
1414 return capability.Catalog{}
1415 }
1416
1417 // parseMCPServerCapabilityID extracts the server name from an mcp-server id.
1418 func parseMCPServerCapabilityID(id string) (string, bool) {
1419 if !strings.HasPrefix(id, "mcp-server:") {
1420 return "", false
1421 }
1422 name := strings.TrimSpace(strings.TrimPrefix(id, "mcp-server:"))
1423 return name, name != ""
1424 }
1425
1426 // resolveServerConnect resolves action=call on an mcp-server id. A connected
1427 // server lists its tools immediately (side-effect free); an unconnected one
1428 // resolves to a deferred connect target that runs only after the permission
1429 // gate and PreToolUse hooks approve it. Stored project authorization is applied
1430 // at resolve time so unauthorized project MCP never reaches process startup.
1431 func (t *UseCapabilityTool) resolveServerConnect(ctx context.Context, server string, base tool.ResolvedCall) (tool.ResolvedCall, error) {
1432 id := "mcp-server:" + server
1433 if !t.serverEnabled(server) {
1434 return t.resolveUnavailable(base, id, plugin.ToolPrefix(server), fmt.Sprintf("MCP server %q is disabled in this session", server)), nil
1435 }
1436 if t.host != nil && t.host.HasClient(server) {
1437 out, err := t.listServerTools(ctx, server)
1438 if err != nil {
1439 return t.resolveUnavailable(base, id, plugin.ToolPrefix(server), err.Error()), nil
1440 }
1441 base.SkipExecute = true
1442 base.HostCompleted = true
1443 base.Result = out
1444 base.ReadOnly = true
1445 return base, nil
1446 }
1447 spec, unlock, err := t.lockAuthorizedRuntimeServer(ctx, server)
1448 if err != nil {
1449 return t.resolveUnavailable(base, id, plugin.ToolPrefix(server), err.Error()), nil
1450 }
1451 unlock()
1452 connect := &onDemandMCPConnect{proxy: t, spec: spec, server: server}
1453 base.Target = connect
1454 // A dedicated exact identity names the connect for permission and hook
1455 // rules. It cannot collide with a real mcp__ tool, and rules do not need to
1456 // rely on unsupported tool-name glob matching.
1457 base.TargetName = connect.Name()
1458 // Connecting spawns a subprocess, so it is never a read-only fast path for
1459 // ordinary Plan/strict agents. PlannerMCPExecution may allow authorized
1460 // connects; unauthorized specs are blocked before process/network start.
1461 base.ReadOnly = false
1462 base.Args = json.RawMessage(`{}`)
1463 return base, nil
1464 }
1465
1466 // onDemandMCPConnect is the deferred first-discovery target: it connects the
1467 // server post-approval and returns the live tool directory.
1468 type onDemandMCPConnect struct {
1469 proxy *UseCapabilityTool
1470 spec plugin.Spec
1471 server string
1472 }
1473
1474 func (o *onDemandMCPConnect) Name() string { return plugin.MCPConnectPermissionName(o.server) }
1475
1476 func (o *onDemandMCPConnect) Description() string {
1477 return "connect MCP server " + o.server + " on demand and list its tools"
1478 }
1479
1480 func (o *onDemandMCPConnect) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
1481
1482 func (o *onDemandMCPConnect) ReadOnly() bool { return false }
1483
1484 // MCPLifecycleConnect marks this target as an MCP connect-and-list lifecycle
1485 // action for Planner authorization (not a remote tools/call).
1486 func (o *onDemandMCPConnect) MCPLifecycleConnect() bool { return true }
1487
1488 func (o *onDemandMCPConnect) MCPServerAuthorized() bool {
1489 return o.spec.ServerAuthorized()
1490 }
1491
1492 func (o *onDemandMCPConnect) MCPServerName() string { return o.server }
1493
1494 func (o *onDemandMCPConnect) ReadOnlyExecutionHostMutation() bool { return true }
1495
1496 func (o *onDemandMCPConnect) ReadOnlyExecutionBlockReason() string {
1497 if !o.spec.ServerAuthorized() {
1498 return "start an unauthorized MCP server (install it or complete project identity approval first)"
1499 }
1500 return "connect this MCP server from a parent session first"
1501 }
1502
1503 func (o *onDemandMCPConnect) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
1504 // Zero process/network start when authorization, enable state, or exact
1505 // runtime identity changed after resolve.
1506 spec, unlock, err := o.proxy.lockAuthorizedRuntimeServer(ctx, o.server)
1507 if err != nil {
1508 msg := err.Error()
1509 if o.proxy.ledger != nil {
1510 o.proxy.ledger.MarkUnavailable("mcp-server:"+o.server, msg)
1511 }
1512 return "", err
1513 }
1514 defer unlock()
1515 if !plugin.MCPRuntimeSpecMatches(spec, o.spec) {
1516 return "", fmt.Errorf("MCP server %q runtime identity changed after resolution; retry so Reasonix can bind the current configuration", o.server)
1517 }
1518 if _, err := o.proxy.ensureServerToolsForSpec(ctx, o.server, spec); err != nil {
1519 if o.proxy.ledger != nil {
1520 o.proxy.ledger.MarkUnavailable("mcp-server:"+o.server, err.Error())
1521 }
1522 return "", err
1523 }
1524 return o.proxy.listServerToolsForSpec(ctx, o.server, spec)
1525 }
1526
1527 // listServerTools renders the live tool directory of a connected server and
1528 // refreshes the proxy snapshot on the way (via serverTools).
1529 func (t *UseCapabilityTool) listServerTools(ctx context.Context, server string) (string, error) {
1530 tools, err := t.serverTools(ctx, server)
1531 if err != nil {
1532 return "", err
1533 }
1534 return fmt.Sprintf("connected MCP server %q; %d tools:\n%s", server, len(tools), inspectToolListJSON(server, tools)), nil
1535 }
1536
1537 func (t *UseCapabilityTool) listServerToolsForSpec(ctx context.Context, server string, spec plugin.Spec) (string, error) {
1538 tools, err := t.serverToolsForSpec(ctx, server, spec)
1539 if err != nil {
1540 return "", err
1541 }
1542 return fmt.Sprintf("connected MCP server %q; %d tools:\n%s", server, len(tools), inspectToolListJSON(server, tools)), nil
1543 }
1544
1545 func parseMCPCapabilityID(id string) (server, raw string, err error) {
1546 id = strings.TrimSpace(id)
1547 switch {
1548 case strings.HasPrefix(id, "mcp-tool:"):
1549 rest := strings.TrimPrefix(id, "mcp-tool:")
1550 server, raw, ok := strings.Cut(rest, "/")
1551 if !ok || server == "" || raw == "" {
1552 return "", "", fmt.Errorf("invalid mcp-tool id %q; want mcp-tool:<server>/<tool>", id)
1553 }
1554 return server, raw, nil
1555 case strings.HasPrefix(id, "mcp-server:"):
1556 return "", "", fmt.Errorf("%q is a server id; call it directly to connect and list tools, or use mcp-tool:<server>/<tool>", id)
1557 default:
1558 return "", "", fmt.Errorf("action=call requires an mcp-tool capability id, got %q", id)
1559 }
1560 }
1561
1562 // Ensure UseCapabilityTool satisfies the tool contracts used by the agent.
1563 var (
1564 _ tool.Tool = (*UseCapabilityTool)(nil)
1565 _ tool.CallResolver = (*UseCapabilityTool)(nil)
1566 )
1567
1568 // EmitProxyAudit is a helper for frontends: returns a notice describing the
1569 // proxy name and real target for user audit trails.
1570 func EmitProxyAudit(sink event.Sink, resolved tool.ResolvedCall) {
1571 if sink == nil || resolved.TargetName == "" {
1572 return
1573 }
1574 sink.Emit(event.Event{
1575 Kind: event.Notice,
1576 Level: event.LevelInfo,
1577 Text: fmt.Sprintf("capability proxy: %s → %s", resolved.DisplayName, resolved.TargetName),
1578 Detail: resolved.CapabilityID,
1579 })
1580 }
1581
1581 lines GO