mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
1454 lines
50 KiB
Go
1454 lines
50 KiB
Go
package usecap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"reasonix/internal/contract/agentcomposition"
|
|
"reasonix/internal/contract/config"
|
|
"reasonix/internal/contract/event"
|
|
"reasonix/internal/contract/tool"
|
|
"reasonix/internal/ext/plugin"
|
|
"reasonix/internal/runtime/capability"
|
|
)
|
|
|
|
// MCPCapabilityRuntime is the session-shared MCP substrate: Host, boot specs,
|
|
// provider-visible registry for already-registered tools, schema cache catalog,
|
|
// and live connection snapshots. Each agent (executor, planner, task/fleet
|
|
// child) gets its own UseCapabilityTool frontend so ledger/audit never cross
|
|
// agent boundaries, while process connections remain on the shared Host.
|
|
type MCPCapabilityRuntime struct {
|
|
lifeCtx context.Context
|
|
host *plugin.Host
|
|
registry *tool.Registry
|
|
catalog func() capability.Catalog
|
|
|
|
// dispatchMu linearizes server enable/spec mutations against MCP process
|
|
// startup and tools/call. Calls may run concurrently under RLock; a disable,
|
|
// uninstall, or hot update waits for in-flight dispatch and invalidates every
|
|
// target that has not begun its final runtime-bound execution check.
|
|
dispatchMu sync.RWMutex
|
|
compositionFrozen bool
|
|
composition *agentcomposition.Definition
|
|
source *inventorySource
|
|
mu sync.RWMutex
|
|
servers map[string]mcpRuntimeServer
|
|
gates mcpServerGates
|
|
// shared connection observation across all frontends on this session.
|
|
state *mcpProxySharedState
|
|
}
|
|
|
|
type mcpRuntimeServer struct {
|
|
entry config.PluginEntry
|
|
spec plugin.Spec
|
|
enabled bool
|
|
cached []plugin.CachedTool
|
|
cacheKeyOK bool
|
|
}
|
|
|
|
type mcpProxySharedState struct {
|
|
mu sync.Mutex
|
|
connected map[string]bool
|
|
liveTools map[string][]plugin.CachedTool
|
|
}
|
|
|
|
// NewMCPCapabilityRuntime builds the session-shared MCP substrate. lifeCtx owns
|
|
// on-demand MCP child process lifetimes; specs must be the boot-converted specs.
|
|
func NewMCPCapabilityRuntime(lifeCtx context.Context, host *plugin.Host, specs []plugin.Spec, reg *tool.Registry, catalog func() capability.Catalog) *MCPCapabilityRuntime {
|
|
r := &MCPCapabilityRuntime{
|
|
lifeCtx: lifeCtx,
|
|
host: host,
|
|
registry: reg,
|
|
catalog: catalog,
|
|
servers: map[string]mcpRuntimeServer{},
|
|
state: &mcpProxySharedState{connected: map[string]bool{}},
|
|
}
|
|
r.ConfigureServers(nil, specs, nil)
|
|
return r
|
|
}
|
|
|
|
// ConfigureServers replaces the runtime's configured MCP inventory. enabled is
|
|
// keyed by server name; nil keeps the standalone/test default that every spec is
|
|
// enabled. Boot passes the activation-resolved set so disabled servers are
|
|
// visible to discovery but cannot reuse a sibling tab's shared Host client.
|
|
func (r *MCPCapabilityRuntime) ConfigureServers(entries []config.PluginEntry, specs []plugin.Spec, enabled map[string]bool) {
|
|
if r == nil {
|
|
return
|
|
}
|
|
r.dispatchMu.Lock()
|
|
defer r.dispatchMu.Unlock()
|
|
byName := make(map[string]config.PluginEntry, len(entries))
|
|
for _, entry := range entries {
|
|
name := strings.TrimSpace(entry.Name)
|
|
if name != "" {
|
|
byName[name] = runtimePluginEntry(entry)
|
|
}
|
|
}
|
|
next := make(map[string]mcpRuntimeServer, len(specs))
|
|
for _, raw := range specs {
|
|
spec := cloneMCPSpec(raw)
|
|
if !r.compositionAllows(spec) {
|
|
continue
|
|
}
|
|
name := strings.TrimSpace(spec.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
entry, ok := byName[name]
|
|
if !ok {
|
|
entry = config.PluginEntry{Name: name}
|
|
}
|
|
isEnabled := true
|
|
if enabled != nil {
|
|
isEnabled = enabled[name]
|
|
}
|
|
cached, keyOK := cachedToolsForSpec(spec)
|
|
next[name] = mcpRuntimeServer{
|
|
entry: entry,
|
|
spec: spec,
|
|
enabled: isEnabled,
|
|
cached: cached,
|
|
cacheKeyOK: keyOK,
|
|
}
|
|
}
|
|
r.mu.Lock()
|
|
previous := r.servers
|
|
r.servers = next
|
|
r.mu.Unlock()
|
|
for name, old := range previous {
|
|
current, exists := next[name]
|
|
if !exists || !current.enabled || old.spec.Authorized != current.spec.Authorized || !plugin.MCPRuntimeSpecMatches(old.spec, current.spec) {
|
|
r.state.clearServer(name)
|
|
if r.source != nil && r.host != nil {
|
|
r.host.Remove(name)
|
|
}
|
|
}
|
|
}
|
|
for name := range next {
|
|
if !next[name].enabled {
|
|
r.state.clearServer(name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// SetServerEnabled revokes or restores this controller's right to use a server.
|
|
// It is intentionally independent from Host connectivity because desktop tabs
|
|
// may share one Host while keeping different enable states.
|
|
func (r *MCPCapabilityRuntime) SetServerEnabled(name string, enabled bool) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
r.dispatchMu.Lock()
|
|
defer r.dispatchMu.Unlock()
|
|
name = strings.TrimSpace(name)
|
|
r.mu.Lock()
|
|
server, ok := r.servers[name]
|
|
if ok {
|
|
server.enabled = enabled
|
|
r.servers[name] = server
|
|
}
|
|
r.mu.Unlock()
|
|
if ok && !enabled {
|
|
r.state.clearServer(name)
|
|
}
|
|
return ok
|
|
}
|
|
|
|
// RemoveServer removes an uninstalled/runtime-only MCP from discovery and
|
|
// clears any live tool snapshot that could otherwise keep it routable.
|
|
func (r *MCPCapabilityRuntime) RemoveServer(name string) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
r.dispatchMu.Lock()
|
|
defer r.dispatchMu.Unlock()
|
|
name = strings.TrimSpace(name)
|
|
r.mu.Lock()
|
|
_, ok := r.servers[name]
|
|
delete(r.servers, name)
|
|
r.mu.Unlock()
|
|
r.state.clearServer(name)
|
|
return ok
|
|
}
|
|
|
|
// CatalogState returns deterministic, privacy-minimal routing inputs for this
|
|
// controller. Configuration secrets are never copied into the transient route.
|
|
func (r *MCPCapabilityRuntime) CatalogState() (entries []config.PluginEntry, cached map[string][]plugin.CachedTool, keyOK map[string]bool, disabled map[string]bool) {
|
|
if r == nil {
|
|
return nil, nil, nil, nil
|
|
}
|
|
r.dispatchMu.RLock()
|
|
defer r.dispatchMu.RUnlock()
|
|
return r.catalogStateLocked()
|
|
}
|
|
|
|
// CapabilityCatalogState returns configuration and live proxy tools from one
|
|
// lifecycle generation. Callers must use this combined snapshot when building
|
|
// a route: taking the two halves separately can otherwise pair a just-updated
|
|
// spec with a stale pre-update live-tool directory.
|
|
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) {
|
|
if r == nil {
|
|
return nil, nil, nil, nil, nil
|
|
}
|
|
if r.refreshInventory() != nil {
|
|
return nil, nil, nil, nil, nil
|
|
}
|
|
r.dispatchMu.RLock()
|
|
defer r.dispatchMu.RUnlock()
|
|
entries, cached, keyOK, disabled = r.catalogStateLocked()
|
|
proxyTools = r.connectedProxyToolsLocked()
|
|
return entries, cached, keyOK, disabled, proxyTools
|
|
}
|
|
|
|
func (r *MCPCapabilityRuntime) catalogStateLocked() (entries []config.PluginEntry, cached map[string][]plugin.CachedTool, keyOK map[string]bool, disabled map[string]bool) {
|
|
r.mu.RLock()
|
|
names := make([]string, 0, len(r.servers))
|
|
for name := range r.servers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
entries = make([]config.PluginEntry, 0, len(names))
|
|
cached = make(map[string][]plugin.CachedTool, len(names))
|
|
keyOK = make(map[string]bool, len(names))
|
|
disabled = make(map[string]bool)
|
|
for _, name := range names {
|
|
server := r.servers[name]
|
|
entries = append(entries, runtimePluginEntry(server.entry))
|
|
if len(server.cached) > 0 {
|
|
cached[name] = cloneCachedTools(server.cached)
|
|
keyOK[name] = server.cacheKeyOK
|
|
}
|
|
if !server.enabled {
|
|
disabled[name] = true
|
|
}
|
|
}
|
|
r.mu.RUnlock()
|
|
if len(cached) == 0 {
|
|
cached = nil
|
|
keyOK = nil
|
|
}
|
|
if len(disabled) == 0 {
|
|
disabled = nil
|
|
}
|
|
return entries, cached, keyOK, disabled
|
|
}
|
|
|
|
func (r *MCPCapabilityRuntime) configuredServers() []mcpRuntimeServer {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
r.mu.RLock()
|
|
names := make([]string, 0, len(r.servers))
|
|
for name := range r.servers {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out := make([]mcpRuntimeServer, 0, len(names))
|
|
for _, name := range names {
|
|
server := r.servers[name]
|
|
server.spec = cloneMCPSpec(server.spec)
|
|
server.entry = runtimePluginEntry(server.entry)
|
|
server.cached = cloneCachedTools(server.cached)
|
|
out = append(out, server)
|
|
}
|
|
r.mu.RUnlock()
|
|
return out
|
|
}
|
|
|
|
func (r *MCPCapabilityRuntime) enabledSpec(server string) (plugin.Spec, bool) {
|
|
if r == nil {
|
|
return plugin.Spec{}, false
|
|
}
|
|
r.mu.RLock()
|
|
configured, ok := r.servers[strings.TrimSpace(server)]
|
|
r.mu.RUnlock()
|
|
if !ok || !configured.enabled {
|
|
return plugin.Spec{}, false
|
|
}
|
|
return cloneMCPSpec(configured.spec), true
|
|
}
|
|
|
|
func (r *MCPCapabilityRuntime) serverEnabled(server string) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
r.mu.RLock()
|
|
configured, ok := r.servers[strings.TrimSpace(server)]
|
|
r.mu.RUnlock()
|
|
return ok && configured.enabled
|
|
}
|
|
|
|
func cachedToolsForSpec(spec plugin.Spec) ([]plugin.CachedTool, bool) {
|
|
cached, keyOK := capability.LoadCachedToolsForSpecs([]plugin.Spec{spec})
|
|
return cloneCachedTools(cached[spec.Name]), keyOK[spec.Name]
|
|
}
|
|
|
|
func runtimePluginEntry(entry config.PluginEntry) config.PluginEntry {
|
|
out := config.PluginEntry{Name: strings.TrimSpace(entry.Name), Source: entry.Source}
|
|
if entry.AutoStart != nil {
|
|
value := *entry.AutoStart
|
|
out.AutoStart = &value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneCachedTools(in []plugin.CachedTool) []plugin.CachedTool {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]plugin.CachedTool, len(in))
|
|
copy(out, in)
|
|
for i := range out {
|
|
out[i].Schema = append(json.RawMessage(nil), in[i].Schema...)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneMCPSpec(in plugin.Spec) plugin.Spec {
|
|
out := in
|
|
out.Args = append([]string(nil), in.Args...)
|
|
out.LaunchArgs = append([]string(nil), in.LaunchArgs...)
|
|
out.LauncherIdentityArgs = append([]string(nil), in.LauncherIdentityArgs...)
|
|
out.Env = cloneStringMap(in.Env)
|
|
out.Headers = cloneStringMap(in.Headers)
|
|
if in.ToolTimeouts != nil {
|
|
out.ToolTimeouts = make(map[string]time.Duration, len(in.ToolTimeouts))
|
|
maps.Copy(out.ToolTimeouts, in.ToolTimeouts)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func cloneStringMap(in map[string]string) map[string]string {
|
|
if in == nil {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
maps.Copy(out, in)
|
|
return out
|
|
}
|
|
|
|
// NewFrontend returns a per-agent use_capability instance. ledger/audit may be
|
|
// nil for ordinary sub-agents that do not run Delivery capability gates.
|
|
func (r *MCPCapabilityRuntime) NewFrontend(ledger *capability.Ledger, audit *capability.Audit) *UseCapabilityTool {
|
|
if r == nil {
|
|
return NewUseCapabilityTool(context.Background(), nil, nil, nil, ledger, audit, nil)
|
|
}
|
|
return &UseCapabilityTool{
|
|
host: r.host,
|
|
lifeCtx: r.lifeCtx,
|
|
runtime: r,
|
|
registry: r.registry,
|
|
ledger: ledger,
|
|
audit: audit,
|
|
catalog: r.catalog,
|
|
state: r.state,
|
|
}
|
|
}
|
|
|
|
// ConnectedProxyTools returns live tool metadata for servers connected through
|
|
// any frontend on this runtime, keyed by server name.
|
|
func (r *MCPCapabilityRuntime) ConnectedProxyTools() map[string][]plugin.CachedTool {
|
|
if r == nil || r.state == nil {
|
|
return nil
|
|
}
|
|
r.dispatchMu.RLock()
|
|
defer r.dispatchMu.RUnlock()
|
|
return r.connectedProxyToolsLocked()
|
|
}
|
|
|
|
func (r *MCPCapabilityRuntime) connectedProxyToolsLocked() map[string][]plugin.CachedTool {
|
|
live := r.state.snapshotLiveTools()
|
|
if len(live) == 0 {
|
|
return nil
|
|
}
|
|
r.mu.RLock()
|
|
for name := range live {
|
|
server, ok := r.servers[name]
|
|
if !ok || !server.enabled {
|
|
delete(live, name)
|
|
}
|
|
}
|
|
r.mu.RUnlock()
|
|
if len(live) == 0 {
|
|
return nil
|
|
}
|
|
return live
|
|
}
|
|
|
|
// UseCapabilityTool is the stable MCP capability proxy for Delivery, the
|
|
// two-model Planner, and task/fleet sub-agents. It lists, inspects, calls, or
|
|
// declines catalog capabilities without adding dynamic MCP tools to the
|
|
// provider-visible registry — subsequent calls keep using this stable schema.
|
|
// Multiple frontends may share one MCPCapabilityRuntime (Host + connection
|
|
// state) while keeping independent ledger/audit.
|
|
type UseCapabilityTool struct {
|
|
host *plugin.Host
|
|
// lifeCtx is the session-scoped context that owns on-demand MCP child
|
|
// processes (mirrors lazySpawn.ctx): a proxied server must outlive the tool
|
|
// call that started it and die with the session, not with a resolve-phase
|
|
// timeout. nil falls back to context.Background() for direct/test use.
|
|
lifeCtx context.Context
|
|
// specs are the boot-converted plugin specs (env expansion, workspace
|
|
// overrides and timeouts). The proxy never rebuilds
|
|
// specs from raw config entries — that would fork the conversion logic.
|
|
specs []plugin.Spec
|
|
runtime *MCPCapabilityRuntime
|
|
registry *tool.Registry // live registry for already-exposed MCP tools
|
|
bindLocal func(tool.Tool) tool.Tool
|
|
ledger *capability.Ledger
|
|
audit *capability.Audit
|
|
catalog func() capability.Catalog
|
|
// state is session-shared connection observation when built via
|
|
// MCPCapabilityRuntime; nil falls back to a private map for tests.
|
|
state *mcpProxySharedState
|
|
}
|
|
|
|
// BindLocalTools installs execution-local file state before frontend publication.
|
|
func (t *UseCapabilityTool) BindLocalTools(bind func(tool.Tool) tool.Tool) { t.bindLocal = bind }
|
|
|
|
// NewUseCapabilityTool builds a standalone capability proxy (tests and simple
|
|
// boots). Prefer MCPCapabilityRuntime.NewFrontend when multiple agents share
|
|
// one session Host.
|
|
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 {
|
|
return &UseCapabilityTool{
|
|
host: host,
|
|
lifeCtx: lifeCtx,
|
|
specs: append([]plugin.Spec(nil), specs...),
|
|
registry: reg,
|
|
ledger: ledger,
|
|
audit: audit,
|
|
catalog: catalog,
|
|
state: &mcpProxySharedState{connected: map[string]bool{}},
|
|
}
|
|
}
|
|
|
|
// CloneForAgent returns a new frontend sharing Host/specs/connection state but
|
|
// with independent ledger and audit (nil unless provided).
|
|
// Execution-local bindings are installed separately before publication.
|
|
func (t *UseCapabilityTool) CloneForAgent(ledger *capability.Ledger, audit *capability.Audit) *UseCapabilityTool {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
state := t.state
|
|
if state == nil {
|
|
state = &mcpProxySharedState{connected: map[string]bool{}}
|
|
}
|
|
return &UseCapabilityTool{
|
|
host: t.host,
|
|
lifeCtx: t.lifeCtx,
|
|
specs: t.specs,
|
|
runtime: t.runtime,
|
|
registry: t.registry,
|
|
ledger: ledger,
|
|
audit: audit,
|
|
catalog: t.catalog,
|
|
state: state,
|
|
}
|
|
}
|
|
|
|
func (*UseCapabilityTool) Name() string { return "use_capability" }
|
|
|
|
// CapabilityIDExamples are the concrete ids the proxy description names. Each
|
|
// must be listed, inspectable, and callable in a real assembly; the boot effect
|
|
// test that reads this stops the description from promising an id the catalog
|
|
// refuses.
|
|
var CapabilityIDExamples = []string{"tool:grep", "skill:review", "task:subagent"}
|
|
|
|
func (*UseCapabilityTool) ReadOnly() bool { return true }
|
|
|
|
func (*UseCapabilityTool) Schema() json.RawMessage {
|
|
// Stable schema — must not change across turns or when MCP connects.
|
|
// capability_id is optional for list/search; inspect/call/decline still
|
|
// require it at resolve time. One intentional prefix upgrade; thereafter
|
|
// install/connect churn does not change this schema.
|
|
return json.RawMessage(`{
|
|
"type":"object",
|
|
"properties":{
|
|
"action":{"type":"string","description":"search | list | inspect | call | decline"},
|
|
"query":{"type":"string","description":"Natural-language search goal"},
|
|
"limit":{"type":"integer","minimum":1,"maximum":20,"description":"Result limit; default 5"},
|
|
"capability_id":{"type":"string","description":"Stable ID required by inspect, call, and decline"},
|
|
"arguments":{"type":"object","description":"Target arguments for call"},
|
|
"reason":{"type":"string","description":"Non-empty decline reason"}
|
|
},
|
|
"required":["action"]
|
|
}`)
|
|
}
|
|
|
|
// ResolveCall implements tool.CallResolver so the agent can run permission,
|
|
// hooks, and evidence against the real MCP target before execution.
|
|
func (t *UseCapabilityTool) ResolveCall(ctx context.Context, args json.RawMessage) (tool.ResolvedCall, error) {
|
|
var p struct {
|
|
Action string `json:"action"`
|
|
CapabilityID string `json:"capability_id"`
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.Unmarshal(args, &p); err != nil {
|
|
return tool.ResolvedCall{}, fmt.Errorf("invalid args: %w", err)
|
|
}
|
|
action := strings.ToLower(strings.TrimSpace(p.Action))
|
|
id := strings.TrimSpace(p.CapabilityID)
|
|
base := tool.ResolvedCall{
|
|
DisplayName: "use_capability",
|
|
ProxyAction: action,
|
|
CapabilityID: id,
|
|
Args: p.Arguments,
|
|
}
|
|
if t.runtime != nil {
|
|
if err := t.runtime.refreshInventory(); err != nil {
|
|
return tool.ResolvedCall{}, err
|
|
}
|
|
}
|
|
if resolved, ok := t.resolveDisabled(base, id, ""); ok {
|
|
return resolved, nil
|
|
}
|
|
switch action {
|
|
case ActionSearch:
|
|
out, err := t.searchCapabilities(p.Query, p.Limit)
|
|
if err != nil {
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(true, false, true)
|
|
}
|
|
return tool.ResolvedCall{}, err
|
|
}
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(true, false, false)
|
|
}
|
|
base.SkipExecute = true
|
|
base.Result = out
|
|
base.ReadOnly = true
|
|
return base, nil
|
|
case ActionList:
|
|
out, err := t.listCapabilities()
|
|
if err != nil {
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(true, false, true)
|
|
}
|
|
return tool.ResolvedCall{}, err
|
|
}
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(true, false, false)
|
|
}
|
|
base.SkipExecute = true
|
|
base.Result = out
|
|
base.ReadOnly = true
|
|
return base, nil
|
|
case ActionInspect:
|
|
if id == "" {
|
|
return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=inspect%s", misplacedArgumentHint(p.Arguments, "capability_id"))
|
|
}
|
|
out, err := t.inspect(ctx, id)
|
|
if err != nil {
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(true, false, true)
|
|
}
|
|
return tool.ResolvedCall{}, err
|
|
}
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(true, false, false)
|
|
}
|
|
base.SkipExecute = true
|
|
base.Result = out
|
|
base.ReadOnly = true
|
|
return base, nil
|
|
case ActionDecline:
|
|
if id == "" {
|
|
return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=decline%s", misplacedArgumentHint(p.Arguments, "capability_id"))
|
|
}
|
|
reason := strings.TrimSpace(p.Reason)
|
|
if reason == "" {
|
|
return tool.ResolvedCall{}, fmt.Errorf("reason is required for action=decline")
|
|
}
|
|
// Decline must not skip require. The mutation itself is delayed until the
|
|
// agent has applied its post-resolution host boundary.
|
|
if t.ledger != nil {
|
|
if e, ok := t.ledger.Get(id); ok && e.Policy == capability.AutoUseRequire {
|
|
return tool.ResolvedCall{}, fmt.Errorf("cannot decline a require capability %q", id)
|
|
}
|
|
}
|
|
base.SkipExecute = true
|
|
base.Result = fmt.Sprintf("declined capability %s: %s", id, reason)
|
|
base.ReadOnly = true
|
|
base.Commit = func() error {
|
|
if t.ledger != nil {
|
|
if err := t.ledger.MarkDeclined(id, reason); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if t.audit != nil {
|
|
t.audit.RecordDecline()
|
|
}
|
|
return nil
|
|
}
|
|
return base, nil
|
|
case ActionCall:
|
|
if id == "" {
|
|
return tool.ResolvedCall{}, fmt.Errorf("capability_id is required for action=call%s", misplacedArgumentHint(p.Arguments, "capability_id"))
|
|
}
|
|
return t.resolveCall(ctx, id, p.Arguments, base)
|
|
default:
|
|
return tool.ResolvedCall{}, fmt.Errorf("unknown action %q; use search, list, inspect, call, or decline", p.Action)
|
|
}
|
|
}
|
|
|
|
// TargetArgs is what a call action forwards to its target, read without
|
|
// resolving it; every other action forwards nothing.
|
|
func (*UseCapabilityTool) TargetArgs(args json.RawMessage) json.RawMessage {
|
|
var p struct {
|
|
Action string `json:"action"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if json.Unmarshal(args, &p) != nil || strings.ToLower(strings.TrimSpace(p.Action)) != "call" {
|
|
return nil
|
|
}
|
|
return p.Arguments
|
|
}
|
|
|
|
func (t *UseCapabilityTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
resolved, err := t.ResolveCall(ctx, args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resolved.SkipExecute {
|
|
if resolved.Commit != nil {
|
|
if err := resolved.Commit(); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
if resolved.ProxyAction == "call" && !resolved.Unavailable {
|
|
if t.ledger != nil {
|
|
t.ledger.MarkSucceeded(resolved.CapabilityID)
|
|
}
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(false, true, false)
|
|
}
|
|
}
|
|
return resolved.Result, nil
|
|
}
|
|
if resolved.Unavailable {
|
|
if t.ledger != nil {
|
|
t.ledger.MarkUnavailable(resolved.CapabilityID, resolved.UnavailableReason)
|
|
}
|
|
return "", fmt.Errorf("capability unavailable: %s", resolved.UnavailableReason)
|
|
}
|
|
if resolved.Target == nil {
|
|
return "", fmt.Errorf("no target tool resolved for %s", resolved.CapabilityID)
|
|
}
|
|
if t.ledger != nil {
|
|
t.ledger.MarkInvoked(resolved.CapabilityID)
|
|
}
|
|
if t.audit != nil {
|
|
t.audit.RecordMCPProxy(false, true, false)
|
|
}
|
|
out, err := resolved.Target.Execute(ctx, resolved.Args)
|
|
if err != nil {
|
|
return out, t.recordCallFailure(resolved, err)
|
|
}
|
|
if t.ledger != nil {
|
|
t.ledger.MarkSucceeded(resolved.CapabilityID)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (t *UseCapabilityTool) inspect(ctx context.Context, id string) (string, error) {
|
|
cat := t.currentCatalog()
|
|
if e, ok := cat.Lookup(id); ok {
|
|
info := map[string]any{
|
|
"id": e.ID,
|
|
"kind": e.Kind,
|
|
"name": e.Name,
|
|
"description": e.Description,
|
|
"status": e.Status,
|
|
"read_only": e.ReadOnly,
|
|
"auto_use": e.AutoUse,
|
|
"requires": e.Requires,
|
|
"profiles": e.Profiles,
|
|
"tool_name": e.ToolName,
|
|
"auto_start": e.AutoStart,
|
|
}
|
|
if len(e.Aliases) > 0 {
|
|
info["aliases"] = e.Aliases
|
|
}
|
|
// A registry tool answers with its own schema. Without it the model is
|
|
// left guessing argument names for a tool the provider schema hides.
|
|
if e.Kind == capability.KindTool && t.registry != nil {
|
|
if tl, ok := t.registry.Get(e.ToolName); ok {
|
|
info["input_schema"] = json.RawMessage(tl.Schema())
|
|
}
|
|
}
|
|
b, _ := json.MarshalIndent(info, "", " ")
|
|
// For MCP entries, list tools without side effects: live tools when the
|
|
// server is already connected, cached schema otherwise. Inspect runs
|
|
// during call resolution — before permission and hook gates — so it must
|
|
// never start a subprocess or open a network connection.
|
|
if e.Kind == capability.KindMCPServer || e.Kind == capability.KindMCPTool {
|
|
server := e.Source
|
|
if server == "" {
|
|
server = e.ConnectName
|
|
}
|
|
toolFilter := ""
|
|
if e.Kind == capability.KindMCPTool {
|
|
parsedServer, raw, err := parseMCPCapabilityID(e.ID)
|
|
if err != nil {
|
|
return string(b), nil
|
|
}
|
|
if server == "" {
|
|
server = parsedServer
|
|
} else if parsedServer != server {
|
|
return string(b), nil
|
|
}
|
|
toolFilter = raw
|
|
}
|
|
if server != "" {
|
|
if !t.serverEnabled(server) {
|
|
return string(b) + "\n\nServer is disabled in this session.", nil
|
|
}
|
|
if t.host != nil && t.host.HasClient(server) {
|
|
// serverTools refreshes the snapshot too: inspecting a
|
|
// server another tab connected restores tool routing here.
|
|
tools, err := t.serverTools(ctx, server)
|
|
if err != nil {
|
|
return string(b) + "\n\nTool listing failed: " + err.Error(), nil
|
|
}
|
|
return string(b) + "\n\nTools:\n" + inspectToolListJSON(server, filterInspectTools(tools, toolFilter)), nil
|
|
}
|
|
if spec, ok := t.specFor(server); ok {
|
|
if cs, ok := plugin.LoadCachedSchemaForSpec(spec); ok && len(cs.Tools) > 0 {
|
|
var list []inspectToolInfo
|
|
for _, ct := range cs.Tools {
|
|
if toolFilter != "" && ct.Name != toolFilter {
|
|
continue
|
|
}
|
|
list = append(list, inspectToolInfo{
|
|
ID: "mcp-tool:" + server + "/" + ct.Name,
|
|
Name: plugin.ModelToolName(server, ct.Name),
|
|
Description: ct.Description,
|
|
ReadOnly: ct.ReadOnly,
|
|
Schema: ct.Schema,
|
|
})
|
|
}
|
|
extra, _ := json.MarshalIndent(list, "", " ")
|
|
return string(b) + "\n\nTools (from cached schema; server not started):\n" + string(extra), nil
|
|
}
|
|
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
|
|
}
|
|
}
|
|
}
|
|
return string(b), nil
|
|
}
|
|
return "", fmt.Errorf("unknown capability_id %q", id)
|
|
}
|
|
|
|
// filterInspectTools narrows concrete mcp-tool inspection to that exact tool.
|
|
// Server inspection intentionally keeps the full directory. This prevents a
|
|
// restricted sub-agent allowed one tool from discovering sibling tool schemas
|
|
// through action=inspect on its allowed capability ID.
|
|
func filterInspectTools(tools []tool.Tool, raw string) []tool.Tool {
|
|
if raw == "" {
|
|
return tools
|
|
}
|
|
filtered := make([]tool.Tool, 0, 1)
|
|
for _, tl := range tools {
|
|
if m, ok := tl.(tool.MCPMetadata); ok && m.MCPRawToolName() == raw {
|
|
filtered = append(filtered, tl)
|
|
break
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
type inspectToolInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
ReadOnly bool `json:"read_only"`
|
|
Schema json.RawMessage `json:"input_schema,omitempty"`
|
|
}
|
|
|
|
// inspectToolListJSON renders a server's live tools as the capability-id
|
|
// directory shared by inspect and the first-discovery connect result.
|
|
func inspectToolListJSON(server string, tools []tool.Tool) string {
|
|
var list []inspectToolInfo
|
|
for _, tl := range tools {
|
|
raw := ""
|
|
if m, ok := tl.(tool.MCPMetadata); ok {
|
|
raw = m.MCPRawToolName()
|
|
}
|
|
list = append(list, inspectToolInfo{
|
|
ID: "mcp-tool:" + server + "/" + raw,
|
|
Name: tl.Name(),
|
|
Description: tl.Description(),
|
|
ReadOnly: tl.ReadOnly(),
|
|
Schema: tl.Schema(),
|
|
})
|
|
}
|
|
extra, _ := json.MarshalIndent(list, "", " ")
|
|
return string(extra)
|
|
}
|
|
|
|
func (t *UseCapabilityTool) resolveCall(ctx context.Context, id string, args json.RawMessage, base tool.ResolvedCall) (tool.ResolvedCall, error) {
|
|
// Registry-backed tools and skills share the unified proxy. Real writers
|
|
// still pass permission/plan/sandbox/lease checks via ResolvedCall.Target.
|
|
if name, ok := strings.CutPrefix(id, "tool:"); ok {
|
|
return t.resolveRegistryTool(strings.TrimSpace(name), id, args, base)
|
|
}
|
|
if name, ok := strings.CutPrefix(id, "skill:"); ok {
|
|
return t.resolveSkillCall(strings.TrimSpace(name), id, args, base)
|
|
}
|
|
// An alias resolves through the catalog that listed it: the id the model
|
|
// read from list/inspect is the id this call answers to.
|
|
if e, ok := t.currentCatalog().Lookup(id); ok && e.Kind == capability.KindTool && e.ToolName != "" {
|
|
return t.resolveRegistryTool(e.ToolName, id, args, base)
|
|
}
|
|
// Server-level call is the first-discovery path for servers with no
|
|
// schema cache: it resolves to a gated connect-and-list target so the
|
|
// model can learn tool names without inspect ever starting a process.
|
|
if server, ok := ParseMCPServerCapabilityID(id); ok {
|
|
return t.resolveServerConnect(ctx, server, base)
|
|
}
|
|
server, raw, err := parseMCPCapabilityID(id)
|
|
if err != nil {
|
|
return tool.ResolvedCall{}, err
|
|
}
|
|
if !t.serverEnabled(server) {
|
|
return t.resolveUnavailable(base, id, plugin.ModelToolName(server, raw), fmt.Sprintf("MCP server %q is disabled in this session", server)), nil
|
|
}
|
|
var runtimeSpec plugin.Spec
|
|
if t.runtime != nil {
|
|
spec, unlock, lockErr := t.lockAuthorizedRuntimeServer(ctx, server)
|
|
if lockErr != nil {
|
|
return t.resolveUnavailable(base, id, plugin.ModelToolName(server, raw), lockErr.Error()), nil
|
|
}
|
|
runtimeSpec = spec
|
|
unlock()
|
|
}
|
|
// Prefer already-exposed registry tool (auto-started MCP). The model name
|
|
// MUST come from the plugin layer's canonical constructor: it appends a
|
|
// collision hash for sanitised raw names, and permission/hook rules are
|
|
// written against that executed name — a proxy-local normalization would
|
|
// let them silently miss.
|
|
modelName := plugin.ModelToolName(server, raw)
|
|
if t.registry != nil {
|
|
if tl, ok := routableRegistryTool(t.registry, modelName); ok {
|
|
if t.runtime != nil && !mcpToolMatchesRuntime(tl, runtimeSpec) {
|
|
return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("connected MCP server %q identity does not match the current runtime configuration", server)), nil
|
|
}
|
|
base.TargetName = modelName
|
|
base.Target = t.bindRuntimeMCP(runtimeSpec, tl)
|
|
base.ReadOnly = tl.ReadOnly()
|
|
if len(args) == 0 {
|
|
base.Args = json.RawMessage(`{}`)
|
|
} else {
|
|
base.Args = args
|
|
}
|
|
return base, nil
|
|
}
|
|
}
|
|
// Server already connected (auto-started, a previous proxy call, or a
|
|
// sibling tab sharing this host): resolving against live tools is
|
|
// side-effect-free. serverTools also refreshes the catalog snapshot so a
|
|
// cross-tab connect still yields routable mcp-tool entries here.
|
|
if t.host != nil && t.host.HasClient(server) {
|
|
var tools []tool.Tool
|
|
if t.runtime != nil {
|
|
tools, err = t.serverToolsForSpec(ctx, server, runtimeSpec)
|
|
} else {
|
|
tools, err = t.serverTools(ctx, server)
|
|
}
|
|
if err != nil {
|
|
return t.resolveUnavailable(base, id, modelName, err.Error()), nil
|
|
}
|
|
target := findMCPTool(tools, raw, modelName)
|
|
if target == nil {
|
|
return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("MCP tool %q not found on server %q", raw, server)), nil
|
|
}
|
|
base.Target = t.bindRuntimeMCP(runtimeSpec, target)
|
|
base.TargetName = target.Name()
|
|
base.ReadOnly = target.ReadOnly()
|
|
if len(args) == 0 {
|
|
base.Args = json.RawMessage(`{}`)
|
|
} else {
|
|
base.Args = args
|
|
}
|
|
return base, nil
|
|
}
|
|
// Unconnected server: resolution must stay pure — no subprocess, no network.
|
|
// Return a deferred target that connects in Execute, after the permission
|
|
// gate and PreToolUse hooks have approved the real target name/arguments.
|
|
spec := runtimeSpec
|
|
if t.runtime == nil {
|
|
var ok bool
|
|
spec, ok = t.specFor(server)
|
|
if !ok {
|
|
return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("MCP server %q is not configured", server)), nil
|
|
}
|
|
spec = plugin.ResolveStoredAuthorization(ctx, spec)
|
|
}
|
|
destructive := false
|
|
if t.catalog != nil {
|
|
if entry, found := t.catalog().Lookup(id); found {
|
|
destructive = entry.Destructive
|
|
}
|
|
}
|
|
readOnly := false
|
|
if cached, found := plugin.CachedToolSafetyForSpec(spec, raw); found {
|
|
destructive = destructive || cached.Destructive
|
|
readOnly = cached.ReadOnly
|
|
}
|
|
lazy := &onDemandMCPTool{proxy: t, spec: spec, server: server, raw: raw, modelName: modelName, destructive: destructive}
|
|
lazy.readOnly = readOnly
|
|
base.Target = lazy
|
|
base.TargetName = modelName
|
|
// Cached server hints control ordinary approval. Strict read-only execution
|
|
// additionally requires server authorization and live read-only metadata.
|
|
base.ReadOnly = lazy.ReadOnly()
|
|
if len(args) == 0 {
|
|
base.Args = json.RawMessage(`{}`)
|
|
} else {
|
|
base.Args = args
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// resolveRegistryTool binds a registry tool by name for use_capability call.
|
|
// Provider-visible core tools remain callable this way, but the catalog prefers
|
|
// listing only non-visible tools so the model uses the top-level surface first.
|
|
func (t *UseCapabilityTool) resolveRegistryTool(name, id string, args json.RawMessage, base tool.ResolvedCall) (tool.ResolvedCall, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return tool.ResolvedCall{}, fmt.Errorf("capability id %q is missing a tool name", id)
|
|
}
|
|
if name == "use_capability" {
|
|
return tool.ResolvedCall{}, fmt.Errorf("cannot proxy use_capability through itself")
|
|
}
|
|
if resolved, ok := t.resolveDisabled(base, id, name); ok {
|
|
return resolved, nil
|
|
}
|
|
if t.registry == nil {
|
|
return t.resolveUnavailable(base, id, name, "tool registry is unavailable"), nil
|
|
}
|
|
tl, ok := t.registry.Get(name)
|
|
if !ok {
|
|
return t.resolveUnavailable(base, id, name, fmt.Sprintf("tool %q is not registered in this session", name)), nil
|
|
}
|
|
if t.bindLocal != nil {
|
|
tl = t.bindLocal(tl)
|
|
}
|
|
base.Target = tl
|
|
base.TargetName = name
|
|
base.ReadOnly = tl.ReadOnly()
|
|
if len(args) == 0 {
|
|
base.Args = json.RawMessage(`{}`)
|
|
} else {
|
|
base.Args = args
|
|
}
|
|
return base, nil
|
|
}
|
|
|
|
// resolveSkillCall routes skill:<name> through run_skill / read_only_skill /
|
|
// read_skill when present, preserving the real skill tool name for evidence.
|
|
func (t *UseCapabilityTool) resolveSkillCall(skillName, id string, args json.RawMessage, base tool.ResolvedCall) (tool.ResolvedCall, error) {
|
|
skillName = strings.TrimSpace(skillName)
|
|
if skillName == "" {
|
|
return tool.ResolvedCall{}, fmt.Errorf("capability id %q is missing a skill name", id)
|
|
}
|
|
if t.registry == nil {
|
|
return t.resolveUnavailable(base, id, skillName, "tool registry is unavailable"), nil
|
|
}
|
|
// Prefer full run_skill; fall back to read-only variants when the session
|
|
// only exposes them (planner / plan mode).
|
|
for _, toolName := range []string{"run_skill", "read_only_skill", "read_skill"} {
|
|
if tl, ok := t.registry.Get(toolName); ok {
|
|
payload := args
|
|
if len(payload) == 0 || string(payload) == "null" {
|
|
payload = json.RawMessage(fmt.Sprintf(`{"name":%q}`, skillName))
|
|
} else {
|
|
var m map[string]any
|
|
if json.Unmarshal(payload, &m) == nil {
|
|
if _, has := m["name"]; !has {
|
|
m["name"] = skillName
|
|
if b, err := json.Marshal(m); err == nil {
|
|
payload = b
|
|
}
|
|
}
|
|
}
|
|
}
|
|
base.Target = tl
|
|
base.TargetName = toolName
|
|
base.ReadOnly = tl.ReadOnly()
|
|
base.Args = payload
|
|
return base, nil
|
|
}
|
|
}
|
|
return t.resolveUnavailable(base, id, skillName, fmt.Sprintf("skill tools are not available for %q", skillName)), nil
|
|
}
|
|
|
|
// findMCPTool matches a server's tool list by raw MCP name or by the
|
|
// canonical namespaced model-visible name (plugin.ModelToolName).
|
|
func findMCPTool(tools []tool.Tool, raw, modelName string) tool.Tool {
|
|
for _, tl := range tools {
|
|
if m, ok := tl.(tool.MCPMetadata); ok && m.MCPRawToolName() == raw {
|
|
return tl
|
|
}
|
|
if tl.Name() == modelName {
|
|
return tl
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// onDemandMCPTool defers MCP server startup to Execute so permission and hook
|
|
// gates always run before any subprocess or network side effect. Before the live
|
|
// handshake it remains write-capable until the resolved MCP tool is classified.
|
|
type onDemandMCPTool struct {
|
|
proxy *UseCapabilityTool
|
|
spec plugin.Spec
|
|
server string
|
|
raw string
|
|
modelName string
|
|
// destructive comes from the schema cache when available. A live promotion
|
|
// is detected in Execute so a retry re-enters the current Plan/read-only
|
|
// execution boundary.
|
|
destructive bool
|
|
readOnly bool
|
|
}
|
|
|
|
func (o *onDemandMCPTool) Name() string { return o.modelName }
|
|
|
|
func (o *onDemandMCPTool) Description() string {
|
|
return "on-demand MCP tool " + o.server + "/" + o.raw + " (connects when first used)"
|
|
}
|
|
|
|
func (o *onDemandMCPTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
|
|
|
func (o *onDemandMCPTool) ReadOnly() bool {
|
|
return o.readOnly
|
|
}
|
|
|
|
func (o *onDemandMCPTool) ReadOnlyExecutionHostMutation() bool { return true }
|
|
|
|
func (o *onDemandMCPTool) MCPServerAuthorized() bool {
|
|
// Spec.Authorized is the single runtime authorization result. Boot/install
|
|
// and ResolveStoredAuthorization set it; this path never invents trust.
|
|
return o.spec.ServerAuthorized()
|
|
}
|
|
|
|
func (o *onDemandMCPTool) ReadOnlyExecutionBlockReason() string {
|
|
return "connect this MCP capability from a parent session first"
|
|
}
|
|
|
|
// MCPServerName/MCPRawToolName expose the deferred target for audit and
|
|
// diagnostics (tool.MCPMetadata).
|
|
func (o *onDemandMCPTool) MCPServerName() string { return o.server }
|
|
func (o *onDemandMCPTool) MCPRawToolName() string { return o.raw }
|
|
func (o *onDemandMCPTool) MCPDestructiveHint() bool {
|
|
return o.destructive
|
|
}
|
|
|
|
func (o *onDemandMCPTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
|
text, _, err := o.executeWithImages(ctx, args)
|
|
return text, err
|
|
}
|
|
|
|
// ExecuteWithImages preserves structured MCP image results on the first call,
|
|
// when the deferred target must connect the server before dispatch. Keeping the
|
|
// resolution and safety checks in executeWithImages ensures text-only and image
|
|
// callers share the same authorization and runtime-identity boundary.
|
|
func (o *onDemandMCPTool) ExecuteWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
|
|
return o.executeWithImages(ctx, args)
|
|
}
|
|
|
|
func (o *onDemandMCPTool) executeWithImages(ctx context.Context, args json.RawMessage) (string, []string, error) {
|
|
// Final runtime-bound authorization and identity check before any
|
|
// process/network start. The read lock also linearizes this dispatch against
|
|
// disable, uninstall, and same-name hot replacement.
|
|
spec, unlock, err := o.proxy.lockAuthorizedRuntimeServer(ctx, o.server)
|
|
if err != nil {
|
|
msg := err.Error()
|
|
if o.proxy.ledger != nil {
|
|
o.proxy.ledger.MarkUnavailable("mcp-tool:"+o.server+"/"+o.raw, msg)
|
|
}
|
|
return "", nil, err
|
|
}
|
|
defer unlock()
|
|
if !plugin.MCPRuntimeSpecMatches(spec, o.spec) {
|
|
return "", nil, fmt.Errorf("MCP server %q runtime identity changed after resolution; retry so Reasonix can bind the current configuration", o.server)
|
|
}
|
|
tools, err := o.proxy.ensureServerToolsForSpec(ctx, o.server, spec)
|
|
if err != nil {
|
|
// Audit for the call path is recorded once by the agent loop
|
|
// (noteCapabilityInvocation); only the ledger outcome lands here.
|
|
if o.proxy.ledger != nil {
|
|
o.proxy.ledger.MarkUnavailable("mcp-tool:"+o.server+"/"+o.raw, err.Error())
|
|
}
|
|
return "", nil, err
|
|
}
|
|
target := findMCPTool(tools, o.raw, o.modelName)
|
|
if target == nil {
|
|
msg := fmt.Sprintf("MCP tool %q not found on server %q", o.raw, o.server)
|
|
if o.proxy.ledger != nil {
|
|
o.proxy.ledger.MarkUnavailable("mcp-tool:"+o.server+"/"+o.raw, msg)
|
|
}
|
|
return "", nil, fmt.Errorf("%s", msg)
|
|
}
|
|
if !mcpToolMatchesRuntime(target, spec) {
|
|
return "", nil, fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration; reconnect this server before retrying", o.server)
|
|
}
|
|
if _, err := plugin.ReconcileCachedToolSafety(o.server, o.raw, plugin.CachedToolSafety{
|
|
ReadOnly: o.readOnly,
|
|
Destructive: o.destructive,
|
|
}, target); err != nil {
|
|
return "", nil, err
|
|
}
|
|
// Planner non-destructive lane and reader lane: re-check live metadata
|
|
// before tools/call even when Reconcile did not see a cache promotion.
|
|
if tool.HasNonDestructiveMCPExecutionIntent(ctx) {
|
|
if !tool.IsMCPServerAuthorized(target) || tool.HasMCPDestructiveHint(target) {
|
|
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)
|
|
}
|
|
}
|
|
if imageTool, ok := target.(tool.ImageTool); ok {
|
|
return imageTool.ExecuteWithImages(ctx, args)
|
|
}
|
|
text, err := target.Execute(ctx, args)
|
|
return text, nil, err
|
|
}
|
|
|
|
func (t *UseCapabilityTool) ensureServerToolsForSpec(ctx context.Context, server string, spec plugin.Spec) ([]tool.Tool, error) {
|
|
// Reuse shared host if already connected (including auto-started).
|
|
if t.host.HasClient(server) {
|
|
return t.serverToolsForSpec(ctx, server, spec)
|
|
}
|
|
// On-demand connect: the child and handshake belong to the session. This
|
|
// tool call waits briefly, but a slow healthy server continues in the
|
|
// background instead of being killed and restarted on every retry. Tools
|
|
// stay off the main provider-visible registry.
|
|
life := t.lifeCtx
|
|
if life == nil {
|
|
life = context.Background()
|
|
}
|
|
result := t.host.EnsureConnectedInBackground(life, spec)
|
|
waitBudget := plugin.DefaultStartupWaitBudget()
|
|
timer := time.NewTimer(waitBudget)
|
|
defer timer.Stop()
|
|
var tools []tool.Tool
|
|
var err error
|
|
select {
|
|
case connected := <-result:
|
|
tools, err = connected.Tools, connected.Err
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-timer.C:
|
|
return nil, fmt.Errorf("MCP server %q is still initializing after %s; startup continues in background (limit %s) — retry on a later turn",
|
|
server, waitBudget, spec.ResolvedStartupTimeout())
|
|
}
|
|
if err != nil {
|
|
if plugin.IsServerAlreadyConnected(err) {
|
|
return t.serverToolsForSpec(ctx, server, spec)
|
|
}
|
|
t.host.RecordFailure(spec, err)
|
|
return nil, fmt.Errorf("connect %q: %w", server, err)
|
|
}
|
|
t.ensureState().markConnected(server)
|
|
// Intentionally do NOT add tools to t.registry — provider schema stays stable.
|
|
_ = tools
|
|
return t.serverToolsForSpec(ctx, server, spec)
|
|
}
|
|
|
|
// serverTools fetches the live tools for a connected server and refreshes the
|
|
// shared catalog snapshot so mcp-tool entries stay routable once the server
|
|
// is StatusReady (its tools are absent from the provider-visible registry).
|
|
func (t *UseCapabilityTool) serverTools(ctx context.Context, server string) ([]tool.Tool, error) {
|
|
spec, unlock, err := t.lockAuthorizedRuntimeServer(ctx, server)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer unlock()
|
|
return t.serverToolsForSpec(ctx, server, spec)
|
|
}
|
|
|
|
func (t *UseCapabilityTool) serverToolsForSpec(ctx context.Context, server string, spec plugin.Spec) ([]tool.Tool, error) {
|
|
tools, err := t.host.ToolsForSpec(ctx, spec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
snap := make([]plugin.CachedTool, 0, len(tools))
|
|
for _, tl := range tools {
|
|
m, ok := tl.(tool.MCPMetadata)
|
|
if !ok || m.MCPRawToolName() == "" {
|
|
continue
|
|
}
|
|
snap = append(snap, plugin.CachedTool{
|
|
Name: m.MCPRawToolName(),
|
|
Description: tl.Description(),
|
|
Schema: tl.Schema(),
|
|
ReadOnly: tl.ReadOnly(),
|
|
Destructive: tool.HasMCPDestructiveHint(tl),
|
|
})
|
|
}
|
|
t.ensureState().setLiveTools(server, snap)
|
|
return tools, nil
|
|
}
|
|
|
|
// ConnectedProxyTools returns raw tool metadata for servers connected through
|
|
// any frontend sharing this proxy state, keyed by server name. Catalog builders
|
|
// consume it so concrete mcp-tool capabilities survive an on-demand connect
|
|
// without ever touching the provider-visible registry.
|
|
func (t *UseCapabilityTool) ConnectedProxyTools() map[string][]plugin.CachedTool {
|
|
if t == nil {
|
|
return nil
|
|
}
|
|
return t.ensureState().snapshotLiveTools()
|
|
}
|
|
|
|
func (t *UseCapabilityTool) ensureState() *mcpProxySharedState {
|
|
if t.state == nil {
|
|
t.state = &mcpProxySharedState{connected: map[string]bool{}}
|
|
}
|
|
return t.state
|
|
}
|
|
|
|
// specFor looks up the boot-converted spec for server. The proxy deliberately
|
|
// holds []plugin.Spec, not raw config entries: env expansion, workspace
|
|
// overrides, call timeouts, and read-only tool names all live in the
|
|
// shared conversion and must not be re-derived here.
|
|
func (t *UseCapabilityTool) specFor(server string) (plugin.Spec, bool) {
|
|
if t.runtime != nil {
|
|
return t.runtime.enabledSpec(server)
|
|
}
|
|
for _, s := range t.specs {
|
|
if s.Name == server {
|
|
return s, true
|
|
}
|
|
}
|
|
return plugin.Spec{}, false
|
|
}
|
|
|
|
// lockAuthorizedRuntimeServer acquires the shared runtime dispatch read lock
|
|
// and returns the current enabled, authorized spec. The caller must keep the
|
|
// returned lock until the identity-bound Host operation or tools/call has
|
|
// crossed its dispatch boundary; lifecycle mutations take the write lock.
|
|
func (t *UseCapabilityTool) lockAuthorizedRuntimeServer(ctx context.Context, server string) (plugin.Spec, func(), error) {
|
|
if t.runtime == nil {
|
|
spec, ok := t.specFor(server)
|
|
if !ok {
|
|
return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not configured", server)
|
|
}
|
|
spec = plugin.ResolveStoredAuthorization(ctx, spec)
|
|
if !spec.ServerAuthorized() {
|
|
return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not authorized; install it or complete project identity approval before connecting", server)
|
|
}
|
|
return spec, func() {}, nil
|
|
}
|
|
|
|
if err := t.runtime.refreshInventory(); err != nil {
|
|
return plugin.Spec{}, func() {}, err
|
|
}
|
|
t.runtime.dispatchMu.RLock()
|
|
unlock := t.runtime.dispatchMu.RUnlock
|
|
t.runtime.mu.RLock()
|
|
configured, ok := t.runtime.servers[strings.TrimSpace(server)]
|
|
t.runtime.mu.RUnlock()
|
|
if !ok {
|
|
unlock()
|
|
return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not configured", server)
|
|
}
|
|
if !configured.enabled {
|
|
unlock()
|
|
return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is disabled in this session", server)
|
|
}
|
|
spec := plugin.ResolveStoredAuthorization(ctx, cloneMCPSpec(configured.spec))
|
|
if !spec.ServerAuthorized() {
|
|
unlock()
|
|
return plugin.Spec{}, func() {}, fmt.Errorf("MCP server %q is not authorized; install it or complete project identity approval before connecting", server)
|
|
}
|
|
return spec, unlock, nil
|
|
}
|
|
|
|
func (t *UseCapabilityTool) bindRuntimeMCP(spec plugin.Spec, target tool.Tool) tool.Tool {
|
|
if t.runtime == nil || target == nil {
|
|
return target
|
|
}
|
|
if bound, ok := target.(*runtimeBoundMCPTool); ok && bound.proxy.runtime == t.runtime {
|
|
return target
|
|
}
|
|
return &runtimeBoundMCPTool{
|
|
proxy: t,
|
|
target: target,
|
|
server: spec.Name,
|
|
authorized: spec.ServerAuthorized(),
|
|
}
|
|
}
|
|
|
|
func (t *UseCapabilityTool) withRuntimeBoundMCP(ctx context.Context, server string, target tool.Tool, execute func() error) error {
|
|
if t.runtime == nil {
|
|
return execute()
|
|
}
|
|
spec, unlock, err := t.lockAuthorizedRuntimeServer(ctx, server)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
if !spec.ToolEnabled(mcpRawToolName(target)) {
|
|
return fmt.Errorf("MCP tool %s is disabled", target.Name())
|
|
}
|
|
if !plugin.MCPToolMatchesSpec(target, spec) {
|
|
return fmt.Errorf("connected MCP server %q identity does not match the current runtime configuration; reconnect this server before retrying", server)
|
|
}
|
|
return t.runtime.withServerGate(ctx, server, execute)
|
|
}
|
|
|
|
func (t *UseCapabilityTool) serverEnabled(server string) bool {
|
|
if t.runtime != nil {
|
|
return t.runtime.serverEnabled(server)
|
|
}
|
|
// Standalone proxies predate the authoritative runtime and may resolve
|
|
// already-registered MCP tools without carrying a duplicate spec slice.
|
|
return true
|
|
}
|
|
|
|
func (t *UseCapabilityTool) configuredServers() []mcpRuntimeServer {
|
|
if t.runtime != nil {
|
|
return t.runtime.configuredServers()
|
|
}
|
|
servers := make([]mcpRuntimeServer, 0, len(t.specs))
|
|
seen := map[string]bool{}
|
|
for _, raw := range t.specs {
|
|
spec := cloneMCPSpec(raw)
|
|
name := strings.TrimSpace(spec.Name)
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
seen[name] = true
|
|
servers = append(servers, mcpRuntimeServer{
|
|
entry: config.PluginEntry{Name: name},
|
|
spec: spec,
|
|
enabled: true,
|
|
})
|
|
}
|
|
sort.Slice(servers, func(i, j int) bool { return servers[i].spec.Name < servers[j].spec.Name })
|
|
return servers
|
|
}
|
|
|
|
func (t *UseCapabilityTool) currentCatalog() capability.Catalog {
|
|
if t.catalog != nil {
|
|
return t.catalog()
|
|
}
|
|
return capability.Catalog{}
|
|
}
|
|
|
|
// ParseMCPServerCapabilityID extracts the server name from an mcp-server id.
|
|
func ParseMCPServerCapabilityID(id string) (string, bool) {
|
|
if !strings.HasPrefix(id, "mcp-server:") {
|
|
return "", false
|
|
}
|
|
name := strings.TrimSpace(strings.TrimPrefix(id, "mcp-server:"))
|
|
return name, name != ""
|
|
}
|
|
|
|
// resolveServerConnect resolves action=call on an mcp-server id. A connected
|
|
// server lists its tools immediately (side-effect free); an unconnected one
|
|
// resolves to a deferred connect target that runs only after the permission
|
|
// gate and PreToolUse hooks approve it. Stored project authorization is applied
|
|
// at resolve time so unauthorized project MCP never reaches process startup.
|
|
func (t *UseCapabilityTool) resolveServerConnect(ctx context.Context, server string, base tool.ResolvedCall) (tool.ResolvedCall, error) {
|
|
id := "mcp-server:" + server
|
|
if !t.serverEnabled(server) {
|
|
return t.resolveUnavailable(base, id, plugin.ToolPrefix(server), fmt.Sprintf("MCP server %q is disabled in this session", server)), nil
|
|
}
|
|
if t.host != nil && t.host.HasClient(server) {
|
|
out, err := t.listServerTools(ctx, server)
|
|
if err != nil {
|
|
return t.resolveUnavailable(base, id, plugin.ToolPrefix(server), err.Error()), nil
|
|
}
|
|
base.SkipExecute = true
|
|
base.HostCompleted = true
|
|
base.Result = out
|
|
base.ReadOnly = true
|
|
return base, nil
|
|
}
|
|
spec, unlock, err := t.lockAuthorizedRuntimeServer(ctx, server)
|
|
if err != nil {
|
|
return t.resolveUnavailable(base, id, plugin.ToolPrefix(server), err.Error()), nil
|
|
}
|
|
unlock()
|
|
connect := &onDemandMCPConnect{proxy: t, spec: spec, server: server}
|
|
base.Target = connect
|
|
// A dedicated exact identity names the connect for permission and hook
|
|
// rules. It cannot collide with a real mcp__ tool, and rules do not need to
|
|
// rely on unsupported tool-name glob matching.
|
|
base.TargetName = connect.Name()
|
|
// Connecting spawns a subprocess, so it is never a read-only fast path for
|
|
// ordinary Plan/strict agents. PlannerMCPExecution may allow authorized
|
|
// connects; unauthorized specs are blocked before process/network start.
|
|
base.ReadOnly = false
|
|
base.Args = json.RawMessage(`{}`)
|
|
return base, nil
|
|
}
|
|
|
|
// listServerTools renders the live tool directory of a connected server and
|
|
// refreshes the proxy snapshot on the way (via serverTools).
|
|
func (t *UseCapabilityTool) listServerTools(ctx context.Context, server string) (string, error) {
|
|
tools, err := t.serverTools(ctx, server)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("connected MCP server %q; %d tools:\n%s", server, len(tools), inspectToolListJSON(server, tools)), nil
|
|
}
|
|
|
|
func (t *UseCapabilityTool) listServerToolsForSpec(ctx context.Context, server string, spec plugin.Spec) (string, error) {
|
|
tools, err := t.serverToolsForSpec(ctx, server, spec)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("connected MCP server %q; %d tools:\n%s", server, len(tools), inspectToolListJSON(server, tools)), nil
|
|
}
|
|
|
|
func parseMCPCapabilityID(id string) (server, raw string, err error) {
|
|
id = strings.TrimSpace(id)
|
|
switch {
|
|
case strings.HasPrefix(id, "mcp-tool:"):
|
|
rest := strings.TrimPrefix(id, "mcp-tool:")
|
|
server, raw, ok := strings.Cut(rest, "/")
|
|
if !ok || server == "" || raw == "" {
|
|
return "", "", fmt.Errorf("invalid mcp-tool id %q; want mcp-tool:<server>/<tool>", id)
|
|
}
|
|
return server, raw, nil
|
|
case strings.HasPrefix(id, "mcp-server:"):
|
|
return "", "", fmt.Errorf("%q is a server id; call it directly to connect and list tools, or use mcp-tool:<server>/<tool>", id)
|
|
default:
|
|
return "", "", fmt.Errorf("action=call requires an mcp-tool capability id, got %q", id)
|
|
}
|
|
}
|
|
|
|
// Ensure UseCapabilityTool satisfies the tool contracts used by the agent.
|
|
var (
|
|
_ tool.Tool = (*UseCapabilityTool)(nil)
|
|
_ tool.CallResolver = (*UseCapabilityTool)(nil)
|
|
_ tool.TargetArgsReader = (*UseCapabilityTool)(nil)
|
|
)
|
|
|
|
// EmitProxyAudit is a helper for frontends: returns a notice describing the
|
|
// proxy name and real target for user audit trails.
|
|
func EmitProxyAudit(sink event.Sink, resolved tool.ResolvedCall) {
|
|
if sink == nil || resolved.TargetName == "" {
|
|
return
|
|
}
|
|
sink.Emit(event.Event{
|
|
Kind: event.Notice,
|
|
Level: event.LevelInfo,
|
|
Audience: event.NoticeAudienceOperator,
|
|
Text: fmt.Sprintf("capability proxy: %s → %s", resolved.DisplayName, resolved.TargetName),
|
|
Detail: resolved.CapabilityID,
|
|
})
|
|
}
|