| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | |
| 9 | "reasonix/internal/capability" |
| 10 | "reasonix/internal/plugin" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | const maxInspectBytes = 16 << 10 |
| 15 | |
| 16 | func (t *UseCapabilityTool) resolveDiscovery(ctx context.Context, p useCapabilityArgs, action, id string, base tool.ResolvedCall) (tool.ResolvedCall, error) { |
| 17 | switch action { |
| 18 | case "list": |
| 19 | out, err := t.listCapabilitiesPage(p.Limit, p.Cursor) |
| 20 | if err != nil { |
| 21 | if t.audit != nil { |
| 22 | t.audit.RecordMCPProxy(true, false, true) |
| 23 | } |
| 24 | return tool.ResolvedCall{}, err |
| 25 | } |
| 26 | if t.audit != nil { |
| 27 | t.audit.RecordMCPProxy(true, false, false) |
| 28 | t.audit.RecordCapabilityDiscovery("list", 0, len(out), false) |
| 29 | } |
| 30 | base.SkipExecute = true |
| 31 | base.Result = out |
| 32 | base.ReadOnly = true |
| 33 | return base, nil |
| 34 | case "search": |
| 35 | query := strings.TrimSpace(p.Query) |
| 36 | if query == "" { |
| 37 | return tool.ResolvedCall{}, capabilityInputErrorf("query is required for action=search") |
| 38 | } |
| 39 | out, resultCount, err := t.searchCapabilities(query, p.Limit) |
| 40 | if err != nil { |
| 41 | return tool.ResolvedCall{}, err |
| 42 | } |
| 43 | base.SkipExecute = true |
| 44 | base.Result = out |
| 45 | base.ReadOnly = true |
| 46 | if t.audit != nil { |
| 47 | t.audit.RecordCapabilityDiscovery("search", resultCount, len(out), false) |
| 48 | } |
| 49 | return base, nil |
| 50 | default: |
| 51 | if id == "" { |
| 52 | return tool.ResolvedCall{}, capabilityInputErrorf("capability_id is required for action=inspect") |
| 53 | } |
| 54 | if id == sessionToolResultCapabilityID { |
| 55 | out, err := t.inspectSessionToolResult() |
| 56 | if err != nil { |
| 57 | return tool.ResolvedCall{}, err |
| 58 | } |
| 59 | base.SkipExecute = true |
| 60 | base.Result = out |
| 61 | base.ReadOnly = true |
| 62 | return base, nil |
| 63 | } |
| 64 | out, err := t.inspect(ctx, id) |
| 65 | if err != nil { |
| 66 | if t.audit != nil { |
| 67 | t.audit.RecordMCPProxy(true, false, true) |
| 68 | } |
| 69 | return tool.ResolvedCall{}, err |
| 70 | } |
| 71 | if t.audit != nil { |
| 72 | t.audit.RecordMCPProxy(true, false, false) |
| 73 | t.audit.RecordCapabilityDiscovery("inspect", 1, len(out), false) |
| 74 | } |
| 75 | base.SkipExecute = true |
| 76 | base.Result = out |
| 77 | base.ReadOnly = true |
| 78 | return base, nil |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | func (t *UseCapabilityTool) inspect(_ context.Context, id string) (string, error) { |
| 83 | cat := t.currentCatalog() |
| 84 | e, ok := cat.Lookup(id) |
| 85 | if !ok { |
| 86 | return "", fmt.Errorf("unknown capability_id %q", id) |
| 87 | } |
| 88 | payload := map[string]any{ |
| 89 | "id": e.ID, |
| 90 | "kind": e.Kind, |
| 91 | "name": e.Name, |
| 92 | "description": e.Description, |
| 93 | "status": e.Status, |
| 94 | "read_only": e.ReadOnly, |
| 95 | "auto_use": e.AutoUse, |
| 96 | "requires": e.Requires, |
| 97 | "profiles": e.Profiles, |
| 98 | "tool_name": e.ToolName, |
| 99 | "auto_start": e.AutoStart, |
| 100 | "network_call": false, |
| 101 | } |
| 102 | if strings.HasPrefix(id, "skill:") { |
| 103 | if contract, ok := capabilityArgumentContract(e); ok { |
| 104 | payload["input_schema"] = contract.Schema |
| 105 | payload["call_example"] = contract.Example |
| 106 | payload["schema_fingerprint"] = tool.SchemaFingerprint(contract.Schema) |
| 107 | } |
| 108 | } |
| 109 | if e.Kind == capability.KindMCPServer || e.Kind == capability.KindMCPTool { |
| 110 | t.decorateMCPInspect(payload, e) |
| 111 | } |
| 112 | return marshalBoundedInspect(payload), nil |
| 113 | } |
| 114 | |
| 115 | func (t *UseCapabilityTool) decorateMCPInspect(payload map[string]any, e capability.Entry) { |
| 116 | server := e.Source |
| 117 | if server == "" { |
| 118 | server = e.ConnectName |
| 119 | } |
| 120 | if server == "" { |
| 121 | return |
| 122 | } |
| 123 | if !t.serverEnabled(server) { |
| 124 | payload["note"] = t.serverUnavailableReason(server) |
| 125 | return |
| 126 | } |
| 127 | tools, source := t.localMCPTools(server) |
| 128 | payload["source"] = source |
| 129 | schemaBytes := 0 |
| 130 | for _, item := range tools { |
| 131 | schemaBytes += len(item.Schema) |
| 132 | } |
| 133 | if t.capabilityAudit() != nil { |
| 134 | t.capabilityAudit().RecordMCPList(source, "inspect", 0, len(tools), schemaBytes) |
| 135 | } |
| 136 | t.observeMCPList(mcpListObservation{ |
| 137 | Server: server, Source: source, Trigger: "inspect", |
| 138 | ToolCount: len(tools), SchemaBytes: schemaBytes, NetworkCall: false, |
| 139 | }) |
| 140 | if e.Kind == capability.KindMCPTool { |
| 141 | _, raw, err := parseMCPCapabilityID(e.ID) |
| 142 | if err != nil { |
| 143 | return |
| 144 | } |
| 145 | selected, selectedSource, found := t.localMCPTool(server, raw) |
| 146 | if !found { |
| 147 | payload["note"] = "Exact schema is not present in the shared-host or disk cache. Call the server capability once to connect, then inspect this exact tool." |
| 148 | return |
| 149 | } |
| 150 | payload["source"] = selectedSource |
| 151 | payload["description"] = selected.Description |
| 152 | payload["read_only"] = selected.ReadOnly |
| 153 | payload["input_schema"] = selected.Schema |
| 154 | payload["schema_fingerprint"] = tool.SchemaFingerprint(selected.Schema) |
| 155 | payload["call_example"] = map[string]any{ |
| 156 | "action": "call", |
| 157 | "capability_id": e.ID, |
| 158 | "arguments": map[string]any{}, |
| 159 | } |
| 160 | return |
| 161 | } |
| 162 | if len(tools) == 0 { |
| 163 | payload["note"] = "Server not connected and no cached tool schema; call action=call on mcp-server:" + server + " to connect after authorization." |
| 164 | return |
| 165 | } |
| 166 | payload["tools"] = compactInspectToolList(server, tools) |
| 167 | payload["note"] = "Compact directory only. Inspect one mcp-tool capability_id to load its full input schema." |
| 168 | } |
| 169 | |
| 170 | func (t *UseCapabilityTool) capabilityAudit() *capability.Audit { |
| 171 | return t.audit |
| 172 | } |
| 173 | |
| 174 | type inspectToolInfo struct { |
| 175 | ID string `json:"id"` |
| 176 | Name string `json:"name"` |
| 177 | Description string `json:"description"` |
| 178 | ReadOnly bool `json:"read_only"` |
| 179 | Fingerprint string `json:"schema_fingerprint,omitempty"` |
| 180 | Schema json.RawMessage `json:"input_schema,omitempty"` |
| 181 | } |
| 182 | |
| 183 | func compactInspectToolList(server string, tools []plugin.CachedTool) []inspectToolInfo { |
| 184 | list := make([]inspectToolInfo, 0, len(tools)) |
| 185 | for _, candidate := range tools { |
| 186 | list = append(list, inspectToolInfo{ |
| 187 | ID: "mcp-tool:" + server + "/" + candidate.Name, |
| 188 | Name: plugin.ModelToolName(server, candidate.Name), |
| 189 | Description: truncateSearchDescription(candidate.Description), |
| 190 | ReadOnly: candidate.ReadOnly, |
| 191 | Fingerprint: tool.SchemaFingerprint(candidate.Schema), |
| 192 | }) |
| 193 | } |
| 194 | return list |
| 195 | } |
| 196 | |
| 197 | func marshalBoundedInspect(payload map[string]any) string { |
| 198 | b, _ := json.MarshalIndent(payload, "", " ") |
| 199 | if len(b) <= maxInspectBytes { |
| 200 | return string(b) |
| 201 | } |
| 202 | if tools, ok := payload["tools"].([]inspectToolInfo); ok { |
| 203 | for len(tools) > 0 && len(b) > maxInspectBytes { |
| 204 | tools = tools[:len(tools)-1] |
| 205 | payload["tools"] = tools |
| 206 | payload["truncated"] = true |
| 207 | b, _ = json.MarshalIndent(payload, "", " ") |
| 208 | } |
| 209 | } |
| 210 | if len(b) > maxInspectBytes { |
| 211 | delete(payload, "input_schema") |
| 212 | payload["schema_omitted"] = "input schema exceeded the 16KB inspect response limit" |
| 213 | b, _ = json.MarshalIndent(payload, "", " ") |
| 214 | } |
| 215 | if len(b) > maxInspectBytes { |
| 216 | // Third-party descriptions and metadata are untrusted and may exceed the |
| 217 | // limit even after schemas/tool rows are removed. Preserve only bounded |
| 218 | // scalar identity fields; never return oversized or invalid JSON. |
| 219 | bounded := map[string]any{ |
| 220 | "truncated": true, |
| 221 | "note": "inspect response exceeded the 16KB limit; narrow the capability_id or page the underlying result", |
| 222 | } |
| 223 | for _, key := range []string{"id", "kind", "name", "status", "source", "schema_fingerprint"} { |
| 224 | if value, ok := payload[key].(string); ok && value != "" { |
| 225 | bounded[key] = truncateInspectString(value, 1024) |
| 226 | } |
| 227 | } |
| 228 | for _, key := range []string{"read_only", "network_call", "auto_start"} { |
| 229 | if value, ok := payload[key].(bool); ok { |
| 230 | bounded[key] = value |
| 231 | } |
| 232 | } |
| 233 | b, _ = json.MarshalIndent(bounded, "", " ") |
| 234 | } |
| 235 | if len(b) > maxInspectBytes { |
| 236 | return `{"truncated":true,"note":"inspect response exceeded the 16KB limit"}` |
| 237 | } |
| 238 | return string(b) |
| 239 | } |
| 240 | |
| 241 | func truncateInspectString(value string, limit int) string { |
| 242 | if len(value) <= limit { |
| 243 | return value |
| 244 | } |
| 245 | return value[:limit-len("...")] + "..." |
| 246 | } |
| 247 | |
| 248 | // inspectToolListJSON renders the compact directory returned after a server |
| 249 | // connection. Exact schemas stay behind inspect(mcp-tool:server/tool). |
| 250 | func inspectToolListJSON(server string, tools []tool.Tool) string { |
| 251 | var list []inspectToolInfo |
| 252 | for _, tl := range tools { |
| 253 | raw := "" |
| 254 | if m, ok := tl.(tool.MCPMetadata); ok { |
| 255 | raw = m.MCPRawToolName() |
| 256 | } |
| 257 | list = append(list, inspectToolInfo{ |
| 258 | ID: "mcp-tool:" + server + "/" + raw, |
| 259 | Name: tl.Name(), |
| 260 | Description: tl.Description(), |
| 261 | ReadOnly: tl.ReadOnly(), |
| 262 | Fingerprint: tool.SchemaFingerprint(tl.Schema()), |
| 263 | }) |
| 264 | } |
| 265 | extra, _ := json.MarshalIndent(list, "", " ") |
| 266 | return string(extra) |
| 267 | } |
| 268 |