diff --git a/CLAUDE.md b/CLAUDE.md index 692eeb8e..eb18e38a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -270,7 +270,7 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da - `service.go` - Service management commands (list, create, get, fork, start, stop, delete, update-password) - `db.go` - Database operation commands (connection-string, connect, test-connection) - `config.go` - Configuration management commands (show, set, unset, reset) - - `mcp.go` - MCP server commands (install, start, list) + - `mcp.go` - MCP server commands (install, start, list, get) - `version.go` - Version command - **Configuration**: `internal/tiger/config/config.go` - Centralized config with Viper integration - **Logging**: `internal/tiger/logging/logging.go` - Structured logging with zap diff --git a/README.md b/README.md index 1d479f28..04c547c1 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Tiger CLI provides the following commands: - `install` - Install and configure MCP server for an AI assistant - `start` - Start the MCP server - `list` - List available MCP tools, prompts, and resources + - `get` - Get detailed information about a specific MCP capability (aliases: `describe`, `show`) - `tiger version` - Show version information Use `tiger --help` for detailed information about each command. diff --git a/internal/tiger/cmd/completion.go b/internal/tiger/cmd/completion.go new file mode 100644 index 00000000..98460c7a --- /dev/null +++ b/internal/tiger/cmd/completion.go @@ -0,0 +1,16 @@ +package cmd + +import "strings" + +// filterCompletionsByPrefix filters a slice of strings to only include items +// that start with the given prefix. This is used by shell completion functions +// to narrow down suggestions based on what the user has typed so far. +func filterCompletionsByPrefix(items []string, prefix string) []string { + var filtered []string + for _, item := range items { + if strings.HasPrefix(item, prefix) { + filtered = append(filtered, item) + } + } + return filtered +} diff --git a/internal/tiger/cmd/config.go b/internal/tiger/cmd/config.go index 04882d26..d58e4b66 100644 --- a/internal/tiger/cmd/config.go +++ b/internal/tiger/cmd/config.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "io" - "strings" "time" "github.com/olekukonko/tablewriter" @@ -234,11 +233,5 @@ func configOptionCompletion(cmd *cobra.Command, args []string, toComplete string return nil, cobra.ShellCompDirectiveNoFileComp } - var results []string - for opt := range config.ValidConfigOptions() { - if strings.HasPrefix(opt, toComplete) { - results = append(results, opt) - } - } - return results, cobra.ShellCompDirectiveNoFileComp + return filterCompletionsByPrefix(config.ValidConfigOptions(), toComplete), cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/tiger/cmd/mcp.go b/internal/tiger/cmd/mcp.go index f7c91917..3783e904 100644 --- a/internal/tiger/cmd/mcp.go +++ b/internal/tiger/cmd/mcp.go @@ -7,11 +7,15 @@ import ( "io" "net" "net/http" + "slices" + "strings" + "github.com/google/jsonschema-go/jsonschema" "github.com/olekukonko/tablewriter" "github.com/spf13/cobra" "go.uber.org/zap" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/timescale/tiger-cli/internal/tiger/config" "github.com/timescale/tiger-cli/internal/tiger/logging" "github.com/timescale/tiger-cli/internal/tiger/mcp" @@ -45,6 +49,7 @@ Use 'tiger mcp start' to launch the MCP server.`, cmd.AddCommand(buildMCPInstallCmd()) cmd.AddCommand(buildMCPStartCmd()) cmd.AddCommand(buildMCPListCmd()) + cmd.AddCommand(buildMCPGetCmd()) return cmd } @@ -279,6 +284,118 @@ Examples: return cmd } +// buildMCPGetCmd creates the get subcommand for displaying detailed info on a specific MCP capability +func buildMCPGetCmd() *cobra.Command { + var outputFormat string + + cmd := &cobra.Command{ + Use: "get ", + Aliases: []string{"describe", "show"}, + Short: "Get detailed information about a specific MCP capability", + Long: `Get detailed information about a specific MCP tool, prompt, resource, or resource template. + +The type argument must be one of: tool, prompt, resource, resource_template + +Examples: + # Get details about a tool + tiger mcp get tool service_create + + # Get details about a prompt + tiger mcp get prompt setup-timescaledb-hypertables + + # Get details as JSON + tiger mcp get tool service_create -o json + + # Get details as YAML + tiger mcp get tool service_create -o yaml`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: mcpGetCompletion, + PreRunE: bindFlags("output"), + RunE: func(cmd *cobra.Command, args []string) error { + // Validate capability type + capabilityType, err := mcp.ValidateCapabilityType(args[0]) + if err != nil { + return err + } + capabilityName := args[1] + + cmd.SilenceUsage = true + + // Get config + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + // Create MCP server + server, err := mcp.NewServer(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to create MCP server: %w", err) + } + defer server.Close() + + // List all capabilities + capabilities, err := server.ListCapabilities(cmd.Context()) + if err != nil { + return fmt.Errorf("failed to list capabilities: %w", err) + } + + // Close the MCP server when finished + if err := server.Close(); err != nil { + return fmt.Errorf("failed to close MCP server: %w", err) + } + + // Find the specific capability + var ( + capability any + found bool + ) + switch capabilityType { + case mcp.CapabilityTypeTool: + capability, found = capabilities.GetTool(capabilityName) + case mcp.CapabilityTypePrompt: + capability, found = capabilities.GetPrompt(capabilityName) + case mcp.CapabilityTypeResource: + capability, found = capabilities.GetResource(capabilityName) + case mcp.CapabilityTypeResourceTemplate: + capability, found = capabilities.GetResourceTemplate(capabilityName) + default: + return fmt.Errorf("unsupported capability type: %s", capabilityType) + } + + if !found { + return fmt.Errorf("%s %q not found", capabilityType, capabilityName) + } + + // Format output + output := cmd.OutOrStdout() + switch cfg.Output { + case "json": + return util.SerializeToJSON(output, capability) + case "yaml": + return util.SerializeToYAML(output, capability) + default: + switch c := capability.(type) { + case *mcpsdk.Tool: + return outputToolText(output, c) + case *mcpsdk.Prompt: + return outputPromptText(output, c) + case *mcpsdk.Resource: + return outputResourceText(output, c) + case *mcpsdk.ResourceTemplate: + return outputResourceTemplateText(output, c) + default: + return fmt.Errorf("unsupported capability type: %T", c) + } + } + }, + } + + cmd.Flags().VarP((*outputFlag)(&outputFormat), "output", "o", "output format (json, yaml, table)") + + return cmd +} + // startStdioServer starts the MCP server with stdio transport func startStdioServer(ctx context.Context) error { logging.Info("Starting Tiger MCP server", zap.String("transport", "stdio")) @@ -405,3 +522,455 @@ func outputCapabilitiesTable(output io.Writer, capabilities *mcp.Capabilities) e return table.Render() } + +// outputToolText outputs a tool in text format +func outputToolText(output io.Writer, tool *mcpsdk.Tool) error { + var lines []string + + // Title line with annotation tags + titleLine := tool.Title + if titleLine == "" { + titleLine = tool.Name + } + + // Add annotation tags to title (each in separate brackets) + if tool.Annotations != nil { + var tags []string + ann := tool.Annotations + + if ann.ReadOnlyHint { + tags = append(tags, "[read-only]") + } + if !ann.ReadOnlyHint && ann.IdempotentHint { + tags = append(tags, "[idempotent]") + } + if !ann.ReadOnlyHint && ann.DestructiveHint != nil && *ann.DestructiveHint { + tags = append(tags, "[destructive]") + } + if ann.OpenWorldHint != nil && *ann.OpenWorldHint { + tags = append(tags, "[open-world]") + } + + if len(tags) > 0 { + titleLine += " " + strings.Join(tags, " ") + } + } + + lines = append(lines, titleLine) + lines = append(lines, "") + + // Tool name + lines = append(lines, "Tool name: "+tool.Name) + lines = append(lines, "") + + // Description + if tool.Description != "" { + lines = append(lines, "Description:") + lines = append(lines, tool.Description) + lines = append(lines, "") + } + + // Parameters (input schema) + if tool.InputSchema != nil { + formatted := formatJSONSchema(tool.InputSchema, 1) + if formatted != "" { + lines = append(lines, "Parameters:") + lines = append(lines, formatted) + lines = append(lines, "") + } + } + + // Output schema + if tool.OutputSchema != nil { + formatted := formatJSONSchema(tool.OutputSchema, 1) + if formatted != "" { + lines = append(lines, "Output:") + lines = append(lines, formatted) + lines = append(lines, "") + } + } + + // Write output + _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) + return err +} + +// outputPromptText outputs a prompt in text format +func outputPromptText(output io.Writer, prompt *mcpsdk.Prompt) error { + var lines []string + + // Title line + titleLine := prompt.Title + if titleLine == "" { + titleLine = prompt.Name + } + + lines = append(lines, titleLine) + lines = append(lines, "") + + // Prompt name + lines = append(lines, "Prompt name: "+prompt.Name) + lines = append(lines, "") + + // Description + if prompt.Description != "" { + lines = append(lines, "Description:") + lines = append(lines, prompt.Description) + lines = append(lines, "") + } + + // Arguments (formatted as bullet list) + if len(prompt.Arguments) > 0 { + lines = append(lines, "Arguments:") + lines = append(lines, formatPromptArguments(prompt.Arguments)) + lines = append(lines, "") + } + + // Write output + _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) + return err +} + +// outputResourceText outputs a resource in text format +func outputResourceText(output io.Writer, resource *mcpsdk.Resource) error { + var lines []string + + // Title line + titleLine := resource.Title + if titleLine == "" { + titleLine = resource.Name + } + + lines = append(lines, titleLine) + lines = append(lines, "") + + // Resource name + lines = append(lines, "Resource name: "+resource.Name) + lines = append(lines, "") + + // Description + if resource.Description != "" { + lines = append(lines, "Description:") + lines = append(lines, resource.Description) + lines = append(lines, "") + } + + // URI + lines = append(lines, "URI: "+resource.URI) + lines = append(lines, "") + + // Optional fields + if resource.MIMEType != "" { + lines = append(lines, "MIME Type: "+resource.MIMEType) + lines = append(lines, "") + } + + if resource.Size > 0 { + lines = append(lines, fmt.Sprintf("Size: %d bytes", resource.Size)) + lines = append(lines, "") + } + + // Annotations + if resource.Annotations != nil { + var annotations []string + ann := resource.Annotations + + if len(ann.Audience) > 0 { + audiences := make([]string, len(ann.Audience)) + for i, role := range ann.Audience { + audiences[i] = string(role) + } + annotations = append(annotations, fmt.Sprintf(" • Audience: %v", audiences)) + } + if ann.Priority != 0 { + annotations = append(annotations, fmt.Sprintf(" • Priority: %f", ann.Priority)) + } + if ann.LastModified != "" { + annotations = append(annotations, " • Last Modified: "+ann.LastModified) + } + + if len(annotations) > 0 { + lines = append(lines, "Annotations:") + lines = append(lines, annotations...) + lines = append(lines, "") + } + } + + // Write output + _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) + return err +} + +// outputResourceTemplateText outputs a resource template in text format +func outputResourceTemplateText(output io.Writer, template *mcpsdk.ResourceTemplate) error { + var lines []string + + // Title line + titleLine := template.Title + if titleLine == "" { + titleLine = template.Name + } + + lines = append(lines, titleLine) + lines = append(lines, "") + + // Resource template name + lines = append(lines, "Resource template name: "+template.Name) + lines = append(lines, "") + + // Description + if template.Description != "" { + lines = append(lines, "Description:") + lines = append(lines, template.Description) + lines = append(lines, "") + } + + // URI Template + lines = append(lines, "URI Template: "+template.URITemplate) + lines = append(lines, "") + + // Optional fields + if template.MIMEType != "" { + lines = append(lines, "MIME Type: "+template.MIMEType) + lines = append(lines, "") + } + + // Annotations + if template.Annotations != nil { + var annotations []string + ann := template.Annotations + + if len(ann.Audience) > 0 { + audiences := make([]string, len(ann.Audience)) + for i, role := range ann.Audience { + audiences[i] = string(role) + } + annotations = append(annotations, fmt.Sprintf(" • Audience: %v", audiences)) + } + if ann.Priority != 0 { + annotations = append(annotations, fmt.Sprintf(" • Priority: %f", ann.Priority)) + } + if ann.LastModified != "" { + annotations = append(annotations, " • Last Modified: "+ann.LastModified) + } + + if len(annotations) > 0 { + lines = append(lines, "Annotations:") + lines = append(lines, annotations...) + lines = append(lines, "") + } + } + + // Write output + _, err := fmt.Fprintln(output, strings.Join(lines, "\n")) + return err +} + +// formatSchemaType recursively formats a JSON schema type into TypeScript-style syntax +func formatSchemaType(prop *jsonschema.Schema) string { + if prop == nil { + return "" + } + + // Handle union types + if len(prop.Types) > 0 { + var types []string + var hasNull bool + for _, t := range prop.Types { + if t == "array" && prop.Items != nil { + // Recursively format array items + itemType := formatSchemaType(prop.Items) + if itemType == "" { + itemType = "any" + } + types = append(types, "[]"+itemType) + } else if t == "null" { + hasNull = true + } else { + types = append(types, t) + } + } + // Put null type at end + if hasNull { + types = append(types, "null") + } + return strings.Join(types, ", ") + } + + // Handle single type + if prop.Type == "array" && prop.Items != nil { + // Recursively format array items + itemType := formatSchemaType(prop.Items) + if itemType == "" { + itemType = "any" + } + return "[]" + itemType + } + + // Return the base type, or "any" if no type is specified + if prop.Type != "" { + return prop.Type + } + return "any" +} + +// formatJSONSchema formats a JSON schema into a readable parameter list +func formatJSONSchema(s *jsonschema.Schema, indent int) string { + if s == nil || len(s.Properties) == 0 { + return "" + } + + // Build formatted output + indentStr := strings.Repeat(" ", indent) + + // Get property names and sort them alphabetically + propNames := make([]string, 0, len(s.Properties)) + for propName := range s.Properties { + propNames = append(propNames, propName) + } + slices.Sort(propNames) + + var lines []string + for _, propName := range propNames { + prop := s.Properties[propName] + if prop == nil { + continue + } + + // Build property line with bullet point + line := indentStr + "• " + propName + + // Add required marker + if slices.Contains(s.Required, propName) { + line += " (required)" + } + + // Add type using recursive formatter + if typeStr := formatSchemaType(prop); typeStr != "" && typeStr != "any" { + line += ": " + typeStr + } + + // Add description + if prop.Description != "" { + line += " - " + prop.Description + } + + // Add default value + if len(prop.Default) > 0 { + line += " (default: " + string(prop.Default) + ")" + } + + lines = append(lines, line) + + if len(prop.Properties) > 0 { + // Handle nested objects + nested := formatJSONSchema(prop, indent+1) + if nested != "" { + lines = append(lines, nested) + } + } else if prop.Items != nil && len(prop.Items.Properties) > 0 { + // Handle nested arrays of objects + nested := formatJSONSchema(prop.Items, indent+1) + if nested != "" { + lines = append(lines, nested) + } + } + } + + return strings.Join(lines, "\n") +} + +// formatPromptArguments formats prompt arguments into a readable bullet-point list +func formatPromptArguments(arguments []*mcpsdk.PromptArgument) string { + if len(arguments) == 0 { + return "" + } + + // Sort arguments alphabetically by name + sortedArgs := slices.Clone(arguments) + slices.SortFunc(sortedArgs, func(a, b *mcpsdk.PromptArgument) int { + return strings.Compare(a.Name, b.Name) + }) + + var lines []string + for _, arg := range sortedArgs { + // Build argument line with bullet point (2-space indent to match schema formatting) + line := " • " + arg.Name + + // Add required marker + if arg.Required { + line += " (required)" + } + + // Add description + if arg.Description != "" { + line += " - " + arg.Description + } + + lines = append(lines, line) + } + + return strings.Join(lines, "\n") +} + +// mcpGetCompletion provides custom completions for the get command +func mcpGetCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + // First argument: capability type + if len(args) == 0 { + return filterCompletionsByPrefix( + mcp.ValidCapabilityTypes().Strings(), toComplete, + ), cobra.ShellCompDirectiveNoFileComp + } + + // Second argument: capability name based on type + if len(args) == 1 { + // Validate capability type + capabilityType, err := mcp.ValidateCapabilityType(args[0]) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Create MCP server to get capabilities + server, err := mcp.NewServer(cmd.Context()) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + defer server.Close() + + capabilities, err := server.ListCapabilities(cmd.Context()) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + // Close the MCP server when finished + if err := server.Close(); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var names []string + switch capabilityType { + case mcp.CapabilityTypeTool: + for _, tool := range capabilities.Tools { + names = append(names, tool.Name) + } + case mcp.CapabilityTypePrompt: + for _, prompt := range capabilities.Prompts { + names = append(names, prompt.Name) + } + case mcp.CapabilityTypeResource: + for _, resource := range capabilities.Resources { + names = append(names, resource.Name) + } + case mcp.CapabilityTypeResourceTemplate: + for _, template := range capabilities.ResourceTemplates { + names = append(names, template.Name) + } + default: + return nil, cobra.ShellCompDirectiveNoFileComp + } + + return filterCompletionsByPrefix(names, toComplete), cobra.ShellCompDirectiveNoFileComp + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/internal/tiger/cmd/mcp_test.go b/internal/tiger/cmd/mcp_test.go index 8285e89d..8138e285 100644 --- a/internal/tiger/cmd/mcp_test.go +++ b/internal/tiger/cmd/mcp_test.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "fmt" "os" "slices" "strings" @@ -15,44 +16,69 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/config" ) -func TestMCPListCommand(t *testing.T) { - // Setup test environment for each subtest - setupMCPListTest := func(t *testing.T) (*cobra.Command, string) { - t.Helper() +// setupMCPTest sets up a test environment for MCP command tests. +// Returns the root command and temporary directory path. +func setupMCPTest(t *testing.T) (*cobra.Command, string) { + t.Helper() - // Use a unique service name for this test to avoid keyring conflicts - setupTestCommand(t) + // Use a unique service name for this test to avoid keyring conflicts + setupTestCommand(t) - // Create temporary directory for test config - tmpDir, err := os.MkdirTemp("", "tiger-mcp-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } + // Create temporary directory for test config + tmpDir, err := os.MkdirTemp("", "tiger-mcp-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } - // Set temporary config directory - os.Setenv("TIGER_CONFIG_DIR", tmpDir) + // Set temporary config directory + os.Setenv("TIGER_CONFIG_DIR", tmpDir) - // Disable analytics for tests - os.Setenv("TIGER_ANALYTICS", "false") + // Disable analytics for tests + os.Setenv("TIGER_ANALYTICS", "false") - // Reset global config and viper to ensure test isolation + // Reset global config and viper to ensure test isolation + config.ResetGlobalConfig() + + t.Cleanup(func() { + // Reset global config and viper first config.ResetGlobalConfig() + // Clean up environment variables BEFORE cleaning up file system + os.Unsetenv("TIGER_CONFIG_DIR") + os.Unsetenv("TIGER_ANALYTICS") + // Then clean up file system + os.RemoveAll(tmpDir) + }) - t.Cleanup(func() { - // Reset global config and viper first - config.ResetGlobalConfig() - // Clean up environment variables BEFORE cleaning up file system - os.Unsetenv("TIGER_CONFIG_DIR") - os.Unsetenv("TIGER_ANALYTICS") - // Then clean up file system - os.RemoveAll(tmpDir) - }) + rootCmd, err := buildRootCmd(t.Context()) + require.NoError(t, err, "should build root command") - rootCmd, err := buildRootCmd(t.Context()) - require.NoError(t, err, "should build root command") + return rootCmd, tmpDir +} - return rootCmd, tmpDir - } +// executeCommand executes a command and returns both output and error +func executeCommand(t *testing.T, rootCmd *cobra.Command, args []string) (string, error) { + t.Helper() + + var buf strings.Builder + rootCmd.SetOut(&buf) + rootCmd.SetErr(&buf) + rootCmd.SetArgs(args) + + err := rootCmd.Execute() + return buf.String(), err +} + +// captureCommandOutput executes a command and returns its output, failing the test if there's an error +func captureCommandOutput(t *testing.T, rootCmd *cobra.Command, args []string) string { + t.Helper() + + output, err := executeCommand(t, rootCmd, args) + require.NoError(t, err, "command should execute successfully") + + return output +} + +func TestMCPListCommand(t *testing.T) { // Expected tools and prompts that should be present in all output formats expectedTools := []string{ @@ -126,8 +152,8 @@ func TestMCPListCommand(t *testing.T) { } } - t.Run("lists capabilities in table format by default", func(t *testing.T) { - rootCmd, _ := setupMCPListTest(t) + t.Run("Table format", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) // Execute the list command output := captureCommandOutput(t, rootCmd, []string{"mcp", "list"}) @@ -156,8 +182,8 @@ func TestMCPListCommand(t *testing.T) { } }) - t.Run("lists capabilities in JSON format", func(t *testing.T) { - rootCmd, _ := setupMCPListTest(t) + t.Run("JSON format", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) // Execute the list command with JSON output output := captureCommandOutput(t, rootCmd, []string{"mcp", "list", "-o", "json"}) @@ -171,8 +197,8 @@ func TestMCPListCommand(t *testing.T) { validateCapabilities(t, capabilities) }) - t.Run("lists capabilities in YAML format", func(t *testing.T) { - rootCmd, _ := setupMCPListTest(t) + t.Run("YAML format", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) // Execute the list command with YAML output output := captureCommandOutput(t, rootCmd, []string{"mcp", "list", "-o", "yaml"}) @@ -186,8 +212,8 @@ func TestMCPListCommand(t *testing.T) { validateCapabilities(t, capabilities) }) - t.Run("handles invalid output format", func(t *testing.T) { - rootCmd, _ := setupMCPListTest(t) + t.Run("Invalid output format", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) // Execute with invalid output format should fail _, err := executeCommand(t, rootCmd, []string{"mcp", "list", "-o", "invalid"}) @@ -195,25 +221,202 @@ func TestMCPListCommand(t *testing.T) { }) } -// executeCommand executes a command and returns both output and error -func executeCommand(t *testing.T, rootCmd *cobra.Command, args []string) (string, error) { - t.Helper() +func TestMCPGetCommand(t *testing.T) { + // toolExpectation defines what sections we expect for each tool + type toolExpectation struct { + name string + parameters bool + output bool + } - var buf strings.Builder - rootCmd.SetOut(&buf) - rootCmd.SetErr(&buf) - rootCmd.SetArgs(args) + // promptExpectation defines what sections we expect for each prompt + type promptExpectation struct { + name string + arguments bool + } - err := rootCmd.Execute() - return buf.String(), err -} + // Expected tools with their section expectations + expectedTools := []toolExpectation{ + {name: "db_execute_query", parameters: true, output: true}, + {name: "semantic_search_postgres_docs", parameters: true, output: true}, + {name: "semantic_search_tiger_docs", parameters: true, output: true}, + {name: "service_create", parameters: true, output: true}, + {name: "service_fork", parameters: true, output: true}, + {name: "service_get", parameters: true, output: true}, + {name: "service_list", parameters: false, output: true}, + {name: "service_start", parameters: true, output: true}, + {name: "service_stop", parameters: true, output: true}, + {name: "service_update_password", parameters: true, output: true}, + {name: "view_skill", parameters: true, output: true}, + } -// captureCommandOutput executes a command and returns its output, failing the test if there's an error -func captureCommandOutput(t *testing.T, rootCmd *cobra.Command, args []string) string { - t.Helper() + // Expected prompts with their section expectations + expectedPrompts := []promptExpectation{ + {name: "design-postgres-tables", arguments: false}, + {name: "find-hypertable-candidates", arguments: false}, + {name: "migrate-postgres-tables-to-hypertables", arguments: false}, + {name: "setup-timescaledb-hypertables", arguments: false}, + } - output, err := executeCommand(t, rootCmd, args) - require.NoError(t, err, "command should execute successfully") + t.Run("Invalid capability type", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) - return output + // Execute with invalid capability type + _, err := executeCommand(t, rootCmd, []string{"mcp", "get", "invalid_type", "some_name"}) + assert.Error(t, err, "should error for invalid capability type") + assert.Contains(t, err.Error(), "invalid capability type", "error should mention invalid capability type") + }) + + t.Run("Invalid tool name", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + + // Execute with valid type but invalid name + _, err := executeCommand(t, rootCmd, []string{"mcp", "get", "tool", "nonexistent_tool"}) + assert.Error(t, err, "should error for nonexistent tool") + assert.Contains(t, err.Error(), "not found", "error should mention tool not found") + }) + + t.Run("Invalid prompt name", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + + // Execute with valid type but invalid name + _, err := executeCommand(t, rootCmd, []string{"mcp", "get", "prompt", "nonexistent-prompt"}) + assert.Error(t, err, "should error for nonexistent prompt") + assert.Contains(t, err.Error(), "not found", "error should mention prompt not found") + }) + + t.Run("Valid tools", func(t *testing.T) { + for _, tool := range expectedTools { + t.Run(tool.name, func(t *testing.T) { + t.Run("Table", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + output := captureCommandOutput(t, rootCmd, []string{"mcp", "get", "tool", tool.name}) + + lines := strings.Split(output, "\n") + require.NotEmpty(t, lines, "output should not be empty") + + // Check for tool name line + assert.Contains(t, output, fmt.Sprintf("Tool name: %s", tool.name), "output should contain tool name line") + + // Check for description section + assert.Contains(t, output, "Description:", "output should contain 'Description:' section") + + // Check for parameters section if expected + if tool.parameters { + assert.Contains(t, output, "Parameters:", "output should contain 'Parameters:' section") + } + + // Check for output section if expected + if tool.output { + assert.Contains(t, output, "Output:", "output should contain 'Output:' section") + } + }) + + t.Run("JSON", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + output := captureCommandOutput(t, rootCmd, []string{"mcp", "get", "tool", tool.name, "-o", "json"}) + + // Should be valid JSON + var toolData map[string]interface{} + err := json.Unmarshal([]byte(output), &toolData) + require.NoError(t, err, "output should be valid JSON") + + // Check for all expected top-level fields + assert.Contains(t, toolData, "name", "tool should have name field") + assert.Contains(t, toolData, "description", "tool should have description field") + assert.Contains(t, toolData, "title", "tool should have title field") + assert.Contains(t, toolData, "annotations", "tool should have annotations field") + assert.Contains(t, toolData, "inputSchema", "tool should have inputSchema field") + assert.Contains(t, toolData, "outputSchema", "tool should have outputSchema field") + assert.Equal(t, tool.name, toolData["name"], "tool name should match") + }) + + t.Run("YAML", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + output := captureCommandOutput(t, rootCmd, []string{"mcp", "get", "tool", tool.name, "-o", "yaml"}) + + // Should be valid YAML + var toolData map[string]interface{} + err := yaml.Unmarshal([]byte(output), &toolData) + require.NoError(t, err, "output should be valid YAML") + + // Check for all expected top-level fields + assert.Contains(t, toolData, "name", "tool should have name field") + assert.Contains(t, toolData, "description", "tool should have description field") + assert.Contains(t, toolData, "title", "tool should have title field") + assert.Contains(t, toolData, "annotations", "tool should have annotations field") + assert.Contains(t, toolData, "inputSchema", "tool should have inputSchema field") + assert.Contains(t, toolData, "outputSchema", "tool should have outputSchema field") + assert.Equal(t, tool.name, toolData["name"], "tool name should match") + }) + }) + } + }) + + t.Run("Valid prompts", func(t *testing.T) { + for _, prompt := range expectedPrompts { + t.Run(prompt.name, func(t *testing.T) { + t.Run("Table", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + output := captureCommandOutput(t, rootCmd, []string{"mcp", "get", "prompt", prompt.name}) + + lines := strings.Split(output, "\n") + require.NotEmpty(t, lines, "output should not be empty") + + // Check for prompt name line + assert.Contains(t, output, fmt.Sprintf("Prompt name: %s", prompt.name), "output should contain prompt name line") + + // Check for description section + assert.Contains(t, output, "Description:", "output should contain 'Description:' section") + + // Check for arguments section if expected + if prompt.arguments { + assert.Contains(t, output, "Arguments:", "output should contain 'Arguments:' section") + } + }) + + t.Run("JSON", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + output := captureCommandOutput(t, rootCmd, []string{"mcp", "get", "prompt", prompt.name, "-o", "json"}) + + // Should be valid JSON + var promptData map[string]interface{} + err := json.Unmarshal([]byte(output), &promptData) + require.NoError(t, err, "output should be valid JSON") + + // Check for all expected top-level fields + assert.Contains(t, promptData, "name", "prompt should have name field") + assert.Contains(t, promptData, "description", "prompt should have description field") + assert.Contains(t, promptData, "title", "prompt should have title field") + assert.Equal(t, prompt.name, promptData["name"], "prompt name should match") + + // Check for arguments field if expected + if prompt.arguments { + assert.Contains(t, promptData, "arguments", "prompt should have arguments field") + } + }) + + t.Run("YAML", func(t *testing.T) { + rootCmd, _ := setupMCPTest(t) + output := captureCommandOutput(t, rootCmd, []string{"mcp", "get", "prompt", prompt.name, "-o", "yaml"}) + + // Should be valid YAML + var promptData map[string]interface{} + err := yaml.Unmarshal([]byte(output), &promptData) + require.NoError(t, err, "output should be valid YAML") + + // Check for all expected top-level fields + assert.Contains(t, promptData, "name", "prompt should have name field") + assert.Contains(t, promptData, "description", "prompt should have description field") + assert.Contains(t, promptData, "title", "prompt should have title field") + assert.Equal(t, prompt.name, promptData["name"], "prompt name should match") + + // Check for arguments field if expected + if prompt.arguments { + assert.Contains(t, promptData, "arguments", "prompt should have arguments field") + } + }) + }) + } + }) } diff --git a/internal/tiger/config/config.go b/internal/tiger/config/config.go index cf364f04..ad261725 100644 --- a/internal/tiger/config/config.go +++ b/internal/tiger/config/config.go @@ -4,10 +4,10 @@ import ( "errors" "fmt" "io/fs" - "iter" "maps" "os" "path/filepath" + "slices" "strconv" "time" @@ -88,8 +88,8 @@ var defaultValues = map[string]any{ "version_check_last_time": time.Time{}, } -func ValidConfigOptions() iter.Seq[string] { - return maps.Keys(defaultValues) +func ValidConfigOptions() []string { + return slices.Collect(maps.Keys(defaultValues)) } func ApplyDefaults(v *viper.Viper) { diff --git a/internal/tiger/mcp/capabilities.go b/internal/tiger/mcp/capabilities.go index 9b58e8c5..e6da18a2 100644 --- a/internal/tiger/mcp/capabilities.go +++ b/internal/tiger/mcp/capabilities.go @@ -3,6 +3,7 @@ package mcp import ( "context" "fmt" + "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/timescale/tiger-cli/internal/tiger/config" @@ -10,6 +11,56 @@ import ( "go.uber.org/zap" ) +// CapabilityType represents a type of MCP capability +type CapabilityType string + +const ( + CapabilityTypeTool CapabilityType = "tool" + CapabilityTypePrompt CapabilityType = "prompt" + CapabilityTypeResource CapabilityType = "resource" + CapabilityTypeResourceTemplate CapabilityType = "resource_template" +) + +// String returns the string representation of the capability type +func (t CapabilityType) String() string { + return string(t) +} + +type CapabilityTypes []CapabilityType + +func (t CapabilityTypes) Strings() []string { + strs := make([]string, len(t)) + for i, t := range t { + strs[i] = t.String() + } + return strs +} + +func (t CapabilityTypes) String() string { + return strings.Join(t.Strings(), ", ") +} + +func ValidCapabilityTypes() CapabilityTypes { + return CapabilityTypes{ + CapabilityTypeTool, + CapabilityTypePrompt, + CapabilityTypeResource, + CapabilityTypeResourceTemplate, + } +} + +// ValidateCapabilityType validates that a string is a valid capability type +// Returns the CapabilityType and nil if valid, or an error if invalid +func ValidateCapabilityType(s string) (CapabilityType, error) { + types := ValidCapabilityTypes() + for _, valid := range types { + if s == string(valid) { + return valid, nil + } + } + return "", fmt.Errorf("invalid capability type %q, must be one of: %s", s, types) +} + // Capabilities holds all MCP server capabilities type Capabilities struct { Tools []*mcp.Tool `json:"tools" yaml:"tools"` @@ -85,3 +136,43 @@ func (s *Server) ListCapabilities(ctx context.Context) (*Capabilities, error) { return capabilities, nil } + +// GetTool finds a tool by name, returns the tool and true if found, nil and false otherwise +func (c *Capabilities) GetTool(name string) (*mcp.Tool, bool) { + for _, tool := range c.Tools { + if tool.Name == name { + return tool, true + } + } + return nil, false +} + +// GetPrompt finds a prompt by name, returns the prompt and true if found, nil and false otherwise +func (c *Capabilities) GetPrompt(name string) (*mcp.Prompt, bool) { + for _, prompt := range c.Prompts { + if prompt.Name == name { + return prompt, true + } + } + return nil, false +} + +// GetResource finds a resource by name, returns the resource and true if found, nil and false otherwise +func (c *Capabilities) GetResource(name string) (*mcp.Resource, bool) { + for _, resource := range c.Resources { + if resource.Name == name { + return resource, true + } + } + return nil, false +} + +// GetResourceTemplate finds a resource template by name, returns the template and true if found, nil and false otherwise +func (c *Capabilities) GetResourceTemplate(name string) (*mcp.ResourceTemplate, bool) { + for _, template := range c.ResourceTemplates { + if template.Name == name { + return template, true + } + } + return nil, false +} diff --git a/internal/tiger/mcp/db_tools.go b/internal/tiger/mcp/db_tools.go index 94009ae7..2f850bbf 100644 --- a/internal/tiger/mcp/db_tools.go +++ b/internal/tiger/mcp/db_tools.go @@ -119,7 +119,10 @@ WARNING: Can execute any SQL statement including INSERT, UPDATE, DELETE, and DDL InputSchema: DBExecuteQueryInput{}.Schema(), OutputSchema: DBExecuteQueryOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, DestructiveHint: util.Ptr(true), // Can execute destructive SQL + IdempotentHint: false, // Queries may have side effects + OpenWorldHint: util.Ptr(true), Title: "Execute SQL Query", }, }, s.handleDBExecuteQuery) diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index 223337b4..d51afffc 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -340,8 +340,9 @@ func (s *Server) registerServiceTools() { InputSchema: ServiceListInput{}.Schema(), OutputSchema: ServiceListOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ - ReadOnlyHint: true, - Title: "List Database Services", + ReadOnlyHint: true, + OpenWorldHint: util.Ptr(true), + Title: "List Database Services", }, }, s.handleServiceList) @@ -354,8 +355,9 @@ func (s *Server) registerServiceTools() { InputSchema: ServiceGetInput{}.Schema(), OutputSchema: ServiceGetOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ - ReadOnlyHint: true, - Title: "Get Service Details", + ReadOnlyHint: true, + OpenWorldHint: util.Ptr(true), + Title: "Get Service Details", }, }, s.handleServiceGet) @@ -373,8 +375,10 @@ WARNING: Creates billable resources.`, InputSchema: ServiceCreateInput{}.Schema(), OutputSchema: ServiceCreateOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, DestructiveHint: util.Ptr(false), // Creates resources but doesn't modify existing IdempotentHint: false, // Creating with same name creates multiple services (name is not unique) + OpenWorldHint: util.Ptr(true), Title: "Create Database Service", }, }, s.handleServiceCreate) @@ -399,8 +403,10 @@ WARNING: Creates billable resources.`, InputSchema: ServiceForkInput{}.Schema(), OutputSchema: ServiceForkOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, DestructiveHint: util.Ptr(false), // Creates resources but doesn't modify existing IdempotentHint: false, // Forking same service multiple times creates multiple forks + OpenWorldHint: util.Ptr(true), Title: "Fork Database Service", }, }, s.handleServiceFork) @@ -414,8 +420,10 @@ WARNING: Creates billable resources.`, InputSchema: ServiceUpdatePasswordInput{}.Schema(), OutputSchema: ServiceUpdatePasswordOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, DestructiveHint: util.Ptr(true), // Modifies authentication credentials IdempotentHint: true, // Same password can be set multiple times + OpenWorldHint: util.Ptr(true), Title: "Update Service Password", }, }, s.handleServiceUpdatePassword) @@ -430,8 +438,10 @@ This operation starts a service that is currently in a stopped/paused state. The InputSchema: ServiceStartInput{}.Schema(), OutputSchema: ServiceStartOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, DestructiveHint: util.Ptr(false), // Starting a service cannot really break anything IdempotentHint: true, // Starting an already-started service is safe (but returns an error) + OpenWorldHint: util.Ptr(true), Title: "Start Database Service", }, }, s.handleServiceStart) @@ -446,8 +456,10 @@ This operation stops a service that is currently running. The service will trans InputSchema: ServiceStopInput{}.Schema(), OutputSchema: ServiceStopOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: false, DestructiveHint: util.Ptr(true), // Stopping a service breaks existing connections and could cause app downtime IdempotentHint: true, // Stopping an already-stopped service is safe (but returns an error) + OpenWorldHint: util.Ptr(true), Title: "Stop Database Service", }, }, s.handleServiceStop)