Files
2026-07-13 13:00:08 +08:00

678 lines
24 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"reasonix/internal/capability"
"reasonix/internal/event"
"reasonix/internal/plugin"
"reasonix/internal/tool"
)
// UseCapabilityTool is the Delivery-only stable proxy for inspecting, calling,
// or declining catalog capabilities. Call never adds dynamic MCP tools to the
// main registry — subsequent calls keep using this stable schema.
type UseCapabilityTool struct {
mu sync.Mutex
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, timeouts, trusted read-only tools). The proxy never rebuilds
// specs from raw config entries — that would fork the conversion logic.
specs []plugin.Spec
registry *tool.Registry // live registry for already-exposed MCP tools
ledger *capability.Ledger
audit *capability.Audit
catalog func() capability.Catalog
// proxyClients holds on-demand connected clients that must not pollute the
// provider-visible registry. Tools are looked up via host.ToolsFor.
connected map[string]bool
// liveTools snapshots raw tool metadata of proxy-connected servers so the
// capability catalog keeps concrete mcp-tool entries after a server turns
// ready — the tools deliberately never enter the provider-visible registry.
liveTools map[string][]plugin.CachedTool
}
// NewUseCapabilityTool builds the Delivery-only capability proxy. lifeCtx is
// the session-scoped context that owns on-demand MCP subprocess lifetimes;
// specs must be the same boot-converted specs used for auto-started servers.
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,
connected: map[string]bool{},
}
}
func (*UseCapabilityTool) Name() string { return "use_capability" }
func (*UseCapabilityTool) Description() string {
return "Delivery profile capability proxy: inspect Skill/MCP metadata, call MCP tools (including auto_start=false servers) without changing the provider tool schema, or decline a prefer capability with a non-empty reason. Skills still use run_skill; this tool only proxies MCP calls."
}
func (*UseCapabilityTool) ReadOnly() bool { return true }
func (*UseCapabilityTool) Schema() json.RawMessage {
// Stable schema — must not change across turns or when MCP connects.
return json.RawMessage(`{
"type":"object",
"properties":{
"action":{"type":"string","description":"inspect | call | decline"},
"capability_id":{"type":"string","description":"Capability id such as skill:review, mcp-server:github, or mcp-tool:github/search_issues"},
"arguments":{"type":"object","description":"Raw MCP tool arguments for action=call"},
"reason":{"type":"string","description":"Required non-empty reason when action=decline"}
},
"required":["action","capability_id"]
}`)
}
// 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"`
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)
if id == "" {
return tool.ResolvedCall{}, fmt.Errorf("capability_id is required")
}
base := tool.ResolvedCall{
DisplayName: "use_capability",
ProxyAction: action,
CapabilityID: id,
Args: p.Arguments,
}
switch action {
case "inspect":
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 "decline":
reason := strings.TrimSpace(p.Reason)
if reason == "" {
return tool.ResolvedCall{}, fmt.Errorf("reason is required for action=decline")
}
// Decline must not skip require.
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)
}
if err := t.ledger.MarkDeclined(id, reason); err != nil {
return tool.ResolvedCall{}, err
}
}
if t.audit != nil {
t.audit.RecordDecline()
}
base.SkipExecute = true
base.Result = fmt.Sprintf("declined capability %s: %s", id, reason)
base.ReadOnly = true
return base, nil
case "call":
return t.resolveCall(ctx, id, p.Arguments, base)
default:
return tool.ResolvedCall{}, fmt.Errorf("unknown action %q; use inspect, call, or decline", p.Action)
}
}
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.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 {
if t.ledger != nil {
t.ledger.MarkFailed(resolved.CapabilityID, err.Error())
}
if t.audit != nil {
t.audit.RecordMCPProxy(false, true, true)
}
return out, 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 {
b, _ := json.MarshalIndent(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,
}, "", " ")
// 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
}
if server != "" {
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, tools), nil
}
if spec, ok := t.specFor(server); ok {
if cs, ok := plugin.LoadCachedSchema(server, plugin.SpecFingerprint(spec)); ok && len(cs.Tools) > 0 {
var list []inspectToolInfo
for _, ct := range cs.Tools {
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)
}
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) {
// 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 {
// Skills must use run_skill.
if strings.HasPrefix(id, "skill:") {
return tool.ResolvedCall{}, fmt.Errorf("call only proxies MCP tools; use run_skill for %s", id)
}
return tool.ResolvedCall{}, err
}
// 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 := t.registry.Get(modelName); ok {
base.TargetName = modelName
base.Target = 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) {
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 = 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, ok := t.specFor(server)
if !ok {
return t.resolveUnavailable(base, id, modelName, fmt.Sprintf("MCP server %q is not configured", server)), nil
}
lazy := &onDemandMCPTool{proxy: t, spec: spec, server: server, raw: raw, modelName: modelName}
base.Target = lazy
base.TargetName = modelName
// Conservative: an unstarted tool counts as a writer unless the user
// explicitly trusted it read-only in config (spec-level trust, the same
// source live remote tools honor).
base.ReadOnly = lazy.ReadOnly()
if len(args) == 0 {
base.Args = json.RawMessage(`{}`)
} else {
base.Args = args
}
return base, nil
}
// resolveUnavailable fills the host-proven unavailable shape shared by the
// side-effect-free resolution failures (missing config, unknown tool).
func (t *UseCapabilityTool) resolveUnavailable(base tool.ResolvedCall, id, modelName, reason string) tool.ResolvedCall {
base.Unavailable = true
base.UnavailableReason = reason
base.SkipExecute = true
base.Result = "capability unavailable: " + reason
base.TargetName = modelName
base.ReadOnly = false
if t.ledger != nil {
t.ledger.MarkUnavailable(id, reason)
}
if t.audit != nil {
t.audit.RecordMCPProxy(false, true, true)
}
return base
}
// 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. It reports
// itself read-only only under config-level trust; everything else is treated
// as a writer until the live handshake proves otherwise, so plan mode and
// permission prompts see the conservative classification.
type onDemandMCPTool struct {
proxy *UseCapabilityTool
spec plugin.Spec
server string
raw string
modelName string
}
func (o *onDemandMCPTool) Name() string { return o.modelName }
func (o *onDemandMCPTool) Description() string {
return "on-demand MCP tool " + o.server + "/" + o.raw + " (connects after approval)"
}
func (o *onDemandMCPTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (o *onDemandMCPTool) ReadOnly() bool {
return o.spec.ReadOnlyToolNames[o.raw] || o.spec.ReadOnlyModelToolNames[o.modelName]
}
// PlanModeUntrustedReadOnly is false because ReadOnly() is true only under an
// explicit user config assertion (trusted_read_only_tools), never a server
// hint — there is no server yet.
func (o *onDemandMCPTool) PlanModeUntrustedReadOnly() bool { return false }
// MCPServerName/MCPRawToolName expose the deferred target for audit and
// plan-mode trust prompts (tool.MCPMetadata).
func (o *onDemandMCPTool) MCPServerName() string { return o.server }
func (o *onDemandMCPTool) MCPRawToolName() string { return o.raw }
func (o *onDemandMCPTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
tools, err := o.proxy.ensureServerTools(ctx, o.server)
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 "", 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 "", fmt.Errorf("%s", msg)
}
return target.Execute(ctx, args)
}
func (t *UseCapabilityTool) ensureServerTools(ctx context.Context, server string) ([]tool.Tool, error) {
server = strings.TrimSpace(server)
if server == "" {
return nil, fmt.Errorf("empty MCP server name")
}
if t.host == nil {
return nil, fmt.Errorf("MCP host unavailable")
}
// Reuse shared host if already connected (including auto-started).
if t.host.HasClient(server) {
return t.serverTools(ctx, server)
}
spec, ok := t.specFor(server)
if !ok {
return nil, fmt.Errorf("MCP server %q is not configured", server)
}
// On-demand connect: the handshake gets a short budget, but the child
// process lifetime belongs to the session-scoped lifeCtx — canceling the
// handshake context after connect must not kill a stdio server the tool
// call is about to use. Tools stay off the main registry.
life := t.lifeCtx
if life == nil {
life = context.Background()
}
handshakeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
tools, err := t.host.AddWithLifecycle(life, handshakeCtx, spec)
if err != nil {
if plugin.IsServerAlreadyConnected(err) {
return t.serverTools(ctx, server)
}
t.host.RecordFailure(spec, err)
return nil, fmt.Errorf("connect %q: %w", server, err)
}
t.mu.Lock()
t.connected[server] = true
t.mu.Unlock()
// Intentionally do NOT add tools to t.registry — Delivery schema stays stable.
_ = tools
return t.serverTools(ctx, server)
}
// serverTools fetches the live tools for a connected server and refreshes the
// proxy's 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) {
tools, err := t.host.ToolsFor(ctx, server)
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(),
})
}
t.mu.Lock()
if t.liveTools == nil {
t.liveTools = map[string][]plugin.CachedTool{}
}
t.liveTools[server] = snap
t.mu.Unlock()
return tools, nil
}
// ConnectedProxyTools returns raw tool metadata for servers connected through
// the proxy, 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 {
t.mu.Lock()
defer t.mu.Unlock()
if len(t.liveTools) == 0 {
return nil
}
out := make(map[string][]plugin.CachedTool, len(t.liveTools))
for k, v := range t.liveTools {
out[k] = append([]plugin.CachedTool(nil), v...)
}
return out
}
// 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 trusted 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) {
for _, s := range t.specs {
if s.Name == server {
return s, true
}
}
return plugin.Spec{}, false
}
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.
func (t *UseCapabilityTool) resolveServerConnect(ctx context.Context, server string, base tool.ResolvedCall) (tool.ResolvedCall, error) {
id := "mcp-server:" + server
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.Result = out
base.ReadOnly = true
return base, nil
}
spec, ok := t.specFor(server)
if !ok {
return t.resolveUnavailable(base, id, plugin.ToolPrefix(server), fmt.Sprintf("MCP server %q is not configured", server)), nil
}
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 — never a read-only fast path; plan mode
// blocks it and Ask-style gates prompt before any process starts.
base.ReadOnly = false
base.Args = json.RawMessage(`{}`)
return base, nil
}
// onDemandMCPConnect is the deferred first-discovery target: it connects the
// server post-approval and returns the live tool directory.
type onDemandMCPConnect struct {
proxy *UseCapabilityTool
spec plugin.Spec
server string
}
func (o *onDemandMCPConnect) Name() string { return plugin.MCPConnectPermissionName(o.server) }
func (o *onDemandMCPConnect) Description() string {
return "connect MCP server " + o.server + " on demand and list its tools"
}
func (o *onDemandMCPConnect) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
func (o *onDemandMCPConnect) ReadOnly() bool { return false }
func (o *onDemandMCPConnect) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
if _, err := o.proxy.ensureServerTools(ctx, o.server); err != nil {
if o.proxy.ledger != nil {
o.proxy.ledger.MarkUnavailable("mcp-server:"+o.server, err.Error())
}
return "", err
}
return o.proxy.listServerTools(ctx, o.server)
}
// 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 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)
)
// 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,
Text: fmt.Sprintf("capability proxy: %s → %s", resolved.DisplayName, resolved.TargetName),
Detail: resolved.CapabilityID,
})
}