diff --git a/internal/tool/agentic_fetch.go b/internal/tool/agentic_fetch.go index eb2ae0d7..8dfed8d4 100644 --- a/internal/tool/agentic_fetch.go +++ b/internal/tool/agentic_fetch.go @@ -39,28 +39,36 @@ func (AgenticFetchTool) Description() string { return "Fetch and intelligently summarize web content using a sub-agent. Better than raw WebFetch for research — the sub-agent extracts only the relevant information." } -func (AgenticFetchTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "url": map[string]interface{}{"type": "string", "description": "URL to fetch and analyze. Provide this OR urls (not both)."}, - "urls": map[string]interface{}{ - "type": "array", - "items": map[string]interface{}{"type": "string"}, - "description": "Multiple URLs to fetch and summarize concurrently against the same query, in a single call.", - }, - "query": map[string]interface{}{"type": "string", "description": "What to look for or extract from the page(s)"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (AgenticFetchTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "url": {Type: "string", Description: "URL to fetch and analyze. Provide this OR urls (not both)."}, + "urls": {Type: "array", Items: &SchemaProperty{Type: "string"}, Description: "Multiple URLs to fetch and summarize concurrently against the same query, in a single call."}, + "query": {Type: "string", Description: "What to look for or extract from the page(s)"}, }, } } +func (AgenticFetchTool) Parameters() map[string]interface{} { + return agenticFetchSchema.ToJSONSchema() +} + +// agenticFetchSchema is the single source of truth for AgenticFetch's input schema. +var agenticFetchSchema = AgenticFetchTool{}.Schema() + +// AgenticFetchInput is the typed input for AgenticFetchTool. +type AgenticFetchInput struct { + URL string `json:"url"` + URLs []string `json:"urls"` + Query string `json:"query"` +} + func (t AgenticFetchTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - URL string `json:"url"` - URLs []string `json:"urls"` - Query string `json:"query"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[AgenticFetchInput]("AgenticFetch", input) + if err != nil { return "", err } diff --git a/internal/tool/mcp_auth.go b/internal/tool/mcp_auth.go index 0758223c..733c2603 100644 --- a/internal/tool/mcp_auth.go +++ b/internal/tool/mcp_auth.go @@ -53,6 +53,13 @@ const mcpAuthCallbackTimeout = 5 * time.Minute // same server_name later to check progress. type McpAuthTool struct{} +// McpAuthInput is the typed input for McpAuthTool. +type McpAuthInput struct { + ServerName string `json:"server_name"` + ServerURL string `json:"server_url"` + ClientID string `json:"client_id"` +} + func (McpAuthTool) Name() string { return "McpAuth" } func (McpAuthTool) Aliases() []string { return []string{"mcp_auth"} } func (McpAuthTool) Description() string { @@ -60,35 +67,30 @@ func (McpAuthTool) Description() string { "Call again with the same server_name to check progress once the user has visited the URL." } -func (McpAuthTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "server_name": map[string]interface{}{ - "type": "string", - "description": "Name of the MCP server to authenticate", - }, - "server_url": map[string]interface{}{ - "type": "string", - "description": "URL of the MCP server", - }, - "client_id": map[string]interface{}{ - "type": "string", - "description": "Optional pre-registered OAuth client_id. If omitted, rho attempts " + - "dynamic client registration (RFC 7591) against the server's advertised registration endpoint.", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (McpAuthTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "server_name": {Type: "string", Description: "Name of the MCP server to authenticate"}, + "server_url": {Type: "string", Description: "URL of the MCP server"}, + "client_id": {Type: "string", Description: "Optional pre-registered OAuth client_id. If omitted, rho attempts dynamic client registration (RFC 7591) against the server's advertised registration endpoint."}, }, - "required": []string{"server_name", "server_url"}, + Required: []string{"server_name", "server_url"}, } } +func (McpAuthTool) Parameters() map[string]interface{} { + return mcpAuthSchema.ToJSONSchema() +} + +// mcpAuthSchema is the single source of truth for McpAuth's input schema. +var mcpAuthSchema = McpAuthTool{}.Schema() + func (McpAuthTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - ServerName string `json:"server_name"` - ServerURL string `json:"server_url"` - ClientID string `json:"client_id"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[McpAuthInput]("McpAuth", input) + if err != nil { return "", err } if p.ServerName == "" { diff --git a/internal/tool/mcp_lsp.go b/internal/tool/mcp_lsp.go index c15cf12c..8e3d3e51 100644 --- a/internal/tool/mcp_lsp.go +++ b/internal/tool/mcp_lsp.go @@ -13,61 +13,51 @@ import ( // across multiple languages without running a language server directly. type MCPLanguageServerTool struct{} +// MCPLanguageServerInput is the typed input for MCPLanguageServerTool. +type MCPLanguageServerInput struct { + Action string `json:"action"` + File string `json:"file"` + Line int `json:"line"` + Column int `json:"column"` + Symbol string `json:"symbol"` + NewName string `json:"newName"` + Language string `json:"language"` +} + func (MCPLanguageServerTool) Name() string { return "MCPLSP" } func (MCPLanguageServerTool) Aliases() []string { return []string{"mcplsp", "lsp-mcp"} } func (MCPLanguageServerTool) Description() string { return "Deep code understanding via MCP language server. Provides go-to-definition, find-references, rename, and diagnostics across multiple languages." } -func (MCPLanguageServerTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "action": map[string]interface{}{ - "type": "string", - "enum": []string{"definition", "references", "rename", "diagnostics", "hover", "symbols"}, - "description": "LSP action to perform", - }, - "file": map[string]interface{}{ - "type": "string", - "description": "File path", - }, - "line": map[string]interface{}{ - "type": "integer", - "description": "Line number (1-based)", - }, - "column": map[string]interface{}{ - "type": "integer", - "description": "Column number (1-based)", - }, - "symbol": map[string]interface{}{ - "type": "string", - "description": "Symbol name (for rename: old name)", - }, - "newName": map[string]interface{}{ - "type": "string", - "description": "New name for rename action", - }, - "language": map[string]interface{}{ - "type": "string", - "description": "Language server to use (go, python, typescript, rust)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (MCPLanguageServerTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "action": {Type: "string", Enum: []interface{}{"definition", "references", "rename", "diagnostics", "hover", "symbols"}, Description: "LSP action to perform"}, + "file": {Type: "string", Description: "File path"}, + "line": {Type: "integer", Description: "Line number (1-based)"}, + "column": {Type: "integer", Description: "Column number (1-based)"}, + "symbol": {Type: "string", Description: "Symbol name (for rename: old name)"}, + "newName": {Type: "string", Description: "New name for rename action"}, + "language": {Type: "string", Description: "Language server to use (go, python, typescript, rust)"}, }, - "required": []string{"action"}, + Required: []string{"action"}, } } +func (MCPLanguageServerTool) Parameters() map[string]interface{} { + return mcpLSPLanguageSchema.ToJSONSchema() +} + +// mcpLSPLanguageSchema is the single source of truth for MCPLanguageServer's input schema. +var mcpLSPLanguageSchema = MCPLanguageServerTool{}.Schema() + func (MCPLanguageServerTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Action string `json:"action"` - File string `json:"file"` - Line int `json:"line"` - Column int `json:"column"` - Symbol string `json:"symbol"` - NewName string `json:"newName"` - Language string `json:"language"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[MCPLanguageServerInput]("MCPLanguageServer", input) + if err != nil { return "", err } diff --git a/internal/tool/mcp_resources.go b/internal/tool/mcp_resources.go index 54563fe3..f3c0dd37 100644 --- a/internal/tool/mcp_resources.go +++ b/internal/tool/mcp_resources.go @@ -10,6 +10,11 @@ import ( type ListMcpResourcesTool struct{} +// ListMcpResourcesInput is the typed input for ListMcpResourcesTool. +type ListMcpResourcesInput struct { + Server string `json:"server"` +} + func (ListMcpResourcesTool) Name() string { return "ListMcpResourcesTool" } func (ListMcpResourcesTool) Aliases() []string { return []string{"list_mcp_resources", "listMcpResources"} @@ -19,23 +24,32 @@ func (ListMcpResourcesTool) Description() string { return "List resources exposed by connected MCP servers. Optionally filter by server name." } -func (ListMcpResourcesTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "server": map[string]interface{}{"type": "string", "description": "Optional MCP server name to filter resources by"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ListMcpResourcesTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "server": {Type: "string", Description: "Optional MCP server name to filter resources by"}, }, } } +func (ListMcpResourcesTool) Parameters() map[string]interface{} { + return listMcpResourcesSchema.ToJSONSchema() +} + +// listMcpResourcesSchema is the single source of truth for ListMcpResources's input schema. +var listMcpResourcesSchema = ListMcpResourcesTool{}.Schema() + func (ListMcpResourcesTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - Server string `json:"server"` - } - if len(input) > 0 { - if err := json.Unmarshal(input, &p); err != nil { + var p ListMcpResourcesInput + if len(input) > 0 && string(input) != "null" { + decoded, err := DecodeInput[ListMcpResourcesInput]("ListMcpResources", input) + if err != nil { return "", err } + p = decoded } type resourceOut struct { @@ -79,6 +93,12 @@ func (ListMcpResourcesTool) Execute(_ context.Context, input json.RawMessage) (s type ReadMcpResourceTool struct{} +// ReadMcpResourceInput is the typed input for ReadMcpResourceTool. +type ReadMcpResourceInput struct { + Server string `json:"server"` + URI string `json:"uri"` +} + func (ReadMcpResourceTool) Name() string { return "ReadMcpResourceTool" } func (ReadMcpResourceTool) Aliases() []string { return []string{"read_mcp_resource", "readMcpResource"} @@ -88,23 +108,29 @@ func (ReadMcpResourceTool) Description() string { return "Read a resource exposed by a connected MCP server." } -func (ReadMcpResourceTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "server": map[string]interface{}{"type": "string", "description": "MCP server name"}, - "uri": map[string]interface{}{"type": "string", "description": "Resource URI"}, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ReadMcpResourceTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "server": {Type: "string", Description: "MCP server name"}, + "uri": {Type: "string", Description: "Resource URI"}, }, - "required": []string{"server", "uri"}, + Required: []string{"server", "uri"}, } } +func (ReadMcpResourceTool) Parameters() map[string]interface{} { + return readMcpResourceSchema.ToJSONSchema() +} + +// readMcpResourceSchema is the single source of truth for ReadMcpResource's input schema. +var readMcpResourceSchema = ReadMcpResourceTool{}.Schema() + func (ReadMcpResourceTool) Execute(_ context.Context, input json.RawMessage) (string, error) { - var p struct { - Server string `json:"server"` - URI string `json:"uri"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[ReadMcpResourceInput]("ReadMcpResource", input) + if err != nil { return "", err } if p.Server == "" || p.URI == "" { diff --git a/internal/tool/nilaway.go b/internal/tool/nilaway.go index 470adc88..2c5814c8 100644 --- a/internal/tool/nilaway.go +++ b/internal/tool/nilaway.go @@ -14,35 +14,40 @@ import ( // function calls, interfaces, and complex control flow. type NilAwayTool struct{} +// NilAwayInput is the typed input for NilAwayTool. +type NilAwayInput struct { + Path string `json:"path"` + Fix bool `json:"fix"` +} + func (NilAwayTool) Name() string { return "NilAway" } func (NilAwayTool) Aliases() []string { return []string{"nilaway", "nil"} } func (NilAwayTool) Description() string { return "Detect potential nil panics using NilAway static analyzer. Catches nil pointer dereferences that other linters miss." } -func (NilAwayTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "Path to analyze (default: current directory)", - }, - "fix": map[string]interface{}{ - "type": "boolean", - "description": "Show suggested fixes", - "default": false, - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (NilAwayTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "path": {Type: "string", Description: "Path to analyze (default: current directory)"}, + "fix": {Type: "boolean", Description: "Show suggested fixes", Default: false}, }, } } +func (NilAwayTool) Parameters() map[string]interface{} { + return nilAwaySchema.ToJSONSchema() +} + +// nilAwaySchema is the single source of truth for NilAway's input schema. +var nilAwaySchema = NilAwayTool{}.Schema() + func (NilAwayTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Path string `json:"path"` - Fix bool `json:"fix"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[NilAwayInput]("NilAway", input) + if err != nil { return "", err } @@ -121,34 +126,40 @@ type Position struct { // Research shows revive is 6x faster than golint with more rules. type ReviveTool struct{} +// ReviveInput is the typed input for ReviveTool. +type ReviveInput struct { + Path string `json:"path"` + Config string `json:"config"` +} + func (ReviveTool) Name() string { return "Revive" } func (ReviveTool) Aliases() []string { return []string{"revive"} } func (ReviveTool) Description() string { return "Fast Go linter (6x faster than golint). Configurable rules for code quality." } -func (ReviveTool) Parameters() map[string]interface{} { - return map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "path": map[string]interface{}{ - "type": "string", - "description": "Path to lint (default: current directory)", - }, - "config": map[string]interface{}{ - "type": "string", - "description": "Config file path (default: uses defaults)", - }, +// Schema returns the typed input schema. Parameters() delegates to it so the +// two cannot diverge. +func (ReviveTool) Schema() ToolSchema { + return ToolSchema{ + Type: "object", + Properties: map[string]SchemaProperty{ + "path": {Type: "string", Description: "Path to lint (default: current directory)"}, + "config": {Type: "string", Description: "Config file path (default: uses defaults)"}, }, } } +func (ReviveTool) Parameters() map[string]interface{} { + return reviveSchema.ToJSONSchema() +} + +// reviveSchema is the single source of truth for Revive's input schema. +var reviveSchema = ReviveTool{}.Schema() + func (ReviveTool) Execute(ctx context.Context, input json.RawMessage) (string, error) { - var p struct { - Path string `json:"path"` - Config string `json:"config"` - } - if err := json.Unmarshal(input, &p); err != nil { + p, err := DecodeInput[ReviveInput]("Revive", input) + if err != nil { return "", err } diff --git a/internal/tool/schema_batch_test.go b/internal/tool/schema_batch_test.go index b54ab4e9..6b9f1db2 100644 --- a/internal/tool/schema_batch_test.go +++ b/internal/tool/schema_batch_test.go @@ -796,3 +796,96 @@ func TestToolHealthSchemaProvider(t *testing.T) { t.Fatal("include_optional type wrong") } } + +func TestMcpAuthSchemaProvider(t *testing.T) { + var _ SchemaProvider = McpAuthTool{} + props := schemaProps(t, McpAuthTool{}.Parameters()) + if props["server_name"].(map[string]interface{})["type"] != "string" { + t.Fatal("server_name type wrong") + } + req, _ := McpAuthTool{}.Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "server_name" || req[1] != "server_url" { + t.Fatalf("required = %v, want [server_name server_url]", McpAuthTool{}.Parameters()["required"]) + } +} + +func TestMCPLanguageServerSchemaProvider(t *testing.T) { + var _ SchemaProvider = MCPLanguageServerTool{} + props := schemaProps(t, MCPLanguageServerTool{}.Parameters()) + enum, ok := props["action"].(map[string]interface{})["enum"].([]interface{}) + if !ok || len(enum) != 6 || enum[0] != "definition" || enum[5] != "symbols" { + t.Fatalf("action enum = %v, want 6 options", props["action"]) + } + req, _ := MCPLanguageServerTool{}.Parameters()["required"].([]string) + if len(req) != 1 || req[0] != "action" { + t.Fatalf("required = %v, want [action]", MCPLanguageServerTool{}.Parameters()["required"]) + } +} + +func TestListMcpResourcesSchemaProvider(t *testing.T) { + var _ SchemaProvider = ListMcpResourcesTool{} + props := schemaProps(t, ListMcpResourcesTool{}.Parameters()) + if props["server"].(map[string]interface{})["type"] != "string" { + t.Fatal("server type wrong") + } +} + +func TestReadMcpResourceSchemaProvider(t *testing.T) { + var _ SchemaProvider = ReadMcpResourceTool{} + props := schemaProps(t, ReadMcpResourceTool{}.Parameters()) + if props["server"].(map[string]interface{})["type"] != "string" { + t.Fatal("server type wrong") + } + if props["uri"].(map[string]interface{})["type"] != "string" { + t.Fatal("uri type wrong") + } + req, _ := ReadMcpResourceTool{}.Parameters()["required"].([]string) + if len(req) != 2 || req[0] != "server" || req[1] != "uri" { + t.Fatalf("required = %v, want [server uri]", ReadMcpResourceTool{}.Parameters()["required"]) + } +} + +func TestNilAwaySchemaProvider(t *testing.T) { + var _ SchemaProvider = NilAwayTool{} + props := schemaProps(t, NilAwayTool{}.Parameters()) + if props["path"].(map[string]interface{})["type"] != "string" { + t.Fatal("path type wrong") + } + if props["fix"].(map[string]interface{})["type"] != "boolean" { + t.Fatal("fix type wrong") + } + fixDefault := props["fix"].(map[string]interface{})["default"] + if fixDefault != false { + t.Fatalf("fix default = %v, want false", fixDefault) + } +} + +func TestReviveSchemaProvider(t *testing.T) { + var _ SchemaProvider = ReviveTool{} + props := schemaProps(t, ReviveTool{}.Parameters()) + if props["path"].(map[string]interface{})["type"] != "string" { + t.Fatal("path type wrong") + } + if props["config"].(map[string]interface{})["type"] != "string" { + t.Fatal("config type wrong") + } +} + +func TestAgenticFetchSchemaProvider(t *testing.T) { + var _ SchemaProvider = AgenticFetchTool{} + props := schemaProps(t, AgenticFetchTool{}.Parameters()) + if props["url"].(map[string]interface{})["type"] != "string" { + t.Fatal("url type wrong") + } + urls := props["urls"].(map[string]interface{}) + if urls["type"] != "array" { + t.Fatalf("urls type = %v, want array", urls["type"]) + } + items, ok := urls["items"].(map[string]interface{}) + if !ok || items["type"] != "string" { + t.Fatalf("urls items = %v, want string", urls["items"]) + } + if props["query"].(map[string]interface{})["type"] != "string" { + t.Fatal("query type wrong") + } +}