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