diff --git a/cmd/mcp.go b/cmd/mcp.go index 2de9827b..2ddd26ef 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "os/signal" @@ -66,6 +67,34 @@ Use --socket to serve over a Unix domain socket (future).`, } cmd.Flags().StringVar(&socketPath, "socket", "", "Unix socket path for TUI+MCP mode (not yet implemented)") + cmd.AddCommand(newMCPToolsCmd()) return cmd } + +const mcpToolsUse = "tools" + +func newMCPToolsCmd() *cobra.Command { + var jsonOut bool + + cmd := &cobra.Command{ + Use: mcpToolsUse, + Short: "List grut MCP tools", + RunE: func(cmd *cobra.Command, args []string) error { + tools := grut_mcp.ToolInventory() + w := cmd.OutOrStdout() + if jsonOut { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(tools) + } + fmt.Fprintf(w, "%-24s %-6s %s\n", "NAME", "TYPE", "DESCRIPTION") + for _, tool := range tools { + fmt.Fprintf(w, "%-24s %-6s %s\n", tool.Name, tool.Category, tool.Description) + } + return nil + }, + } + cmd.Flags().BoolVar(&jsonOut, "json", false, "Print tools as JSON") + return cmd +} diff --git a/cmd/mcp_tools_test.go b/cmd/mcp_tools_test.go new file mode 100644 index 00000000..ab63e5dd --- /dev/null +++ b/cmd/mcp_tools_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + grut_mcp "github.com/jongio/grut/internal/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMCPToolsCommandListsTools(t *testing.T) { + cmd := newMCPCmd() + cmd.SetArgs([]string{"tools"}) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + assert.Contains(t, out.String(), "NAME") + assert.Contains(t, out.String(), "git_status") + assert.Contains(t, out.String(), "file_read") +} + +func TestMCPToolsCommandJSON(t *testing.T) { + cmd := newMCPCmd() + cmd.SetArgs([]string{"tools", "--json"}) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + var tools []grut_mcp.ToolInfo + require.NoError(t, json.Unmarshal(out.Bytes(), &tools)) + assert.NotEmpty(t, tools) + assert.Equal(t, "git_status", tools[0].Name) +} diff --git a/internal/mcp/inventory.go b/internal/mcp/inventory.go new file mode 100644 index 00000000..1e14b0a7 --- /dev/null +++ b/internal/mcp/inventory.go @@ -0,0 +1,29 @@ +package mcp + +import ( + "slices" + + mcpserver "github.com/mark3labs/mcp-go/server" +) + +type ToolInfo struct { + Name string `json:"name"` + Category string `json:"category"` + Description string `json:"description"` +} + +func ToolInventory() []ToolInfo { + s := &Server{ + mcp: mcpserver.NewMCPServer("grut", "inventory"), + } + registerGitReadTools(s) + registerGitWriteTools(s) + registerGitOpsTools(s) + registerFileTools(s) + return s.Tools() +} + +func (s *Server) Tools() []ToolInfo { + out := slices.Clone(s.tools) + return out +} diff --git a/internal/mcp/inventory_test.go b/internal/mcp/inventory_test.go new file mode 100644 index 00000000..9e43cd21 --- /dev/null +++ b/internal/mcp/inventory_test.go @@ -0,0 +1,34 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestToolInventoryIncludesReadWriteAndFileTools(t *testing.T) { + tools := ToolInventory() + require.NotEmpty(t, tools) + + byName := make(map[string]ToolInfo, len(tools)) + for _, tool := range tools { + byName[tool.Name] = tool + } + + assert.Equal(t, categoryRead, byName["git_status"].Category) + assert.Equal(t, categoryWrite, byName["git_stage"].Category) + assert.Equal(t, categoryRead, byName["file_read"].Category) + assert.Equal(t, categoryWrite, byName["file_write"].Category) + assert.NotEmpty(t, byName["git_status"].Description) +} + +func TestServerToolsReturnsCopy(t *testing.T) { + tools := ToolInventory() + require.NotEmpty(t, tools) + + tools[0].Name = "changed" + again := ToolInventory() + + assert.NotEqual(t, "changed", again[0].Name) +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 8b237bf0..6b1df30b 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -30,6 +30,7 @@ type Server struct { limiter *RateLimiter audit *AuditLogger repoRoot string + tools []ToolInfo } // Default rate limits (calls per minute) when not configured. @@ -92,6 +93,11 @@ func (s *Server) MCPServer() *mcpserver.MCPServer { // addTool registers a tool with security middleware wrapping. func (s *Server) addTool(name string, category string, tool mcplib.Tool, handler mcpserver.ToolHandlerFunc) { + s.tools = append(s.tools, ToolInfo{ + Name: name, + Category: category, + Description: tool.Description, + }) s.mcp.AddTool(tool, s.wrapHandler(name, category, handler)) }