678 lines
24 KiB
Go
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,
|
|
})
|
|
}
|