diff --git a/CHANGELOG.md b/CHANGELOG.md index 67849699..55410952 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ Notable changes to clippy. ## [Unreleased] +### Added + +- MCP metadata overrides now support partial files by default, with `--strict-metadata` for full coverage validation +- MCP server metadata is loaded from `server.json` to keep defaults in a single source of truth + ## [1.6.4] - 2026-01-15 ### Added diff --git a/README.md b/README.md index f8a293ac..78a6a69c 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,19 @@ Add to your config (`~/Library/Application Support/Claude/claude_desktop_config. } ``` +### Metadata Overrides (Optional) + +You can customize MCP tool/prompt/example descriptions without changing behavior: + +```bash +clippy mcp-server \ + --tools /path/to/tools.json \ + --prompts /path/to/prompts.json \ + --examples /path/to/examples.json +``` + +By default, override files can be partial. Add `--strict-metadata` to require full coverage of every tool, prompt, and parameter. + ### Available Tools #### System Clipboard Tools diff --git a/cmd/clippy/main.go b/cmd/clippy/main.go index d0b5fc13..23c89b13 100644 --- a/cmd/clippy/main.go +++ b/cmd/clippy/main.go @@ -227,6 +227,11 @@ MCP Server: rootCmd.PersistentFlags().StringVarP(&mimeType, "mime", "m", "", "Manually specify MIME type for clipboard (e.g., text/html, application/json, text/xml)") // Add MCP server subcommand + var mcpExamplesPath string + var mcpToolsPath string + var mcpPromptsPath string + var mcpStrictMetadata bool + var mcpCmd = &cobra.Command{ Use: "mcp-server", Short: "Start MCP server for AI/LLM integration", @@ -251,13 +256,23 @@ Add to ~/Library/Application Support/Claude/claude_desktop_config.json: }`, Run: func(cmd *cobra.Command, args []string) { fmt.Fprintln(os.Stderr, "Starting Clippy MCP server...") - if err := mcp.StartServer(); err != nil { + if err := mcp.StartServerWithOptions(mcp.ServerOptions{ + ExamplesPath: mcpExamplesPath, + ToolsPath: mcpToolsPath, + PromptsPath: mcpPromptsPath, + StrictMetadata: mcpStrictMetadata, + }); err != nil { fmt.Fprintf(os.Stderr, "MCP server error: %v\n", err) os.Exit(1) } }, } + mcpCmd.Flags().StringVar(&mcpExamplesPath, "examples", "", "Path to JSON file with MCP examples overrides") + mcpCmd.Flags().StringVar(&mcpToolsPath, "tools", "", "Path to JSON file with MCP tool description overrides") + mcpCmd.Flags().StringVar(&mcpPromptsPath, "prompts", "", "Path to JSON file with MCP prompt overrides") + mcpCmd.Flags().BoolVar(&mcpStrictMetadata, "strict-metadata", false, "Require override files to provide descriptions for every tool/prompt/parameter") + rootCmd.AddCommand(mcpCmd) // Execute the command diff --git a/cmd/clippy/mcp/metadata.go b/cmd/clippy/mcp/metadata.go new file mode 100644 index 00000000..002c5c41 --- /dev/null +++ b/cmd/clippy/mcp/metadata.go @@ -0,0 +1,615 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/neilberkman/clippy" +) + +// ServerOptions controls optional MCP metadata overrides. +type ServerOptions struct { + ExamplesPath string + ToolsPath string + PromptsPath string + StrictMetadata bool +} + +// ServerMetadata describes the MCP server's tools, prompts, and examples. +type ServerMetadata struct { + Tools []ToolSpec + Prompts []PromptSpec + Examples []ExampleSpec +} + +// ToolSpec describes a tool and its parameters. +type ToolSpec struct { + Name string + Description string + Params []ToolParamSpec +} + +// ToolParamSpec describes a tool parameter. +type ToolParamSpec struct { + Name string + Description string + Type string + Required bool +} + +// PromptSpec describes a prompt and its arguments. +type PromptSpec struct { + Name string + Description string + Arguments []PromptArgSpec +} + +// PromptArgSpec describes a prompt argument. +type PromptArgSpec struct { + Name string + Description string + Required bool +} + +// ExampleSpec describes a prompt example. +type ExampleSpec struct { + Prompt string `json:"prompt"` + Description string `json:"description"` +} + +type serverJSON struct { + Tools []serverTool `json:"tools"` + Prompts []PromptSpec `json:"prompts"` + Examples []ExampleSpec `json:"examples"` +} + +type serverTool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters serverToolParams `json:"parameters"` +} + +type serverToolParams struct { + Properties map[string]serverToolParam `json:"properties"` + Required []string `json:"required"` +} + +type serverToolParam struct { + Type string `json:"type"` + Description string `json:"description"` +} + +// DefaultServerMetadata returns the built-in MCP metadata definitions. +func DefaultServerMetadata() (ServerMetadata, error) { + return loadServerMetadataFromJSON(clippy.DefaultServerJSON) +} + +func loadServerMetadataFromJSON(data []byte) (ServerMetadata, error) { + if len(bytes.TrimSpace(data)) == 0 { + return ServerMetadata{}, fmt.Errorf("default server metadata is empty") + } + + var payload serverJSON + if err := json.Unmarshal(data, &payload); err != nil { + return ServerMetadata{}, fmt.Errorf("parse default server metadata: %w", err) + } + if len(payload.Tools) == 0 { + return ServerMetadata{}, fmt.Errorf("default server metadata is missing tools") + } + + tools := make([]ToolSpec, 0, len(payload.Tools)) + for _, tool := range payload.Tools { + if tool.Name == "" { + return ServerMetadata{}, fmt.Errorf("default tool is missing name") + } + if strings.TrimSpace(tool.Description) == "" { + return ServerMetadata{}, fmt.Errorf("default tool %q is missing description", tool.Name) + } + + requiredSet := make(map[string]bool, len(tool.Parameters.Required)) + for _, name := range tool.Parameters.Required { + requiredSet[name] = true + } + + params := make([]ToolParamSpec, 0, len(tool.Parameters.Properties)) + for name, param := range tool.Parameters.Properties { + if name == "" { + return ServerMetadata{}, fmt.Errorf("default tool %q has a parameter with no name", tool.Name) + } + if strings.TrimSpace(param.Description) == "" { + return ServerMetadata{}, fmt.Errorf("default tool %q parameter %q missing description", tool.Name, name) + } + if strings.TrimSpace(param.Type) == "" { + return ServerMetadata{}, fmt.Errorf("default tool %q parameter %q missing type", tool.Name, name) + } + params = append(params, ToolParamSpec{ + Name: name, + Description: param.Description, + Type: param.Type, + Required: requiredSet[name], + }) + } + tools = append(tools, ToolSpec{ + Name: tool.Name, + Description: tool.Description, + Params: params, + }) + } + + return ServerMetadata{ + Tools: tools, + Prompts: payload.Prompts, + Examples: payload.Examples, + }, nil +} + +// LoadServerMetadata loads default metadata and applies any overrides. +func LoadServerMetadata(opts ServerOptions) (ServerMetadata, error) { + metadata, err := DefaultServerMetadata() + if err != nil { + return ServerMetadata{}, err + } + + if opts.ToolsPath != "" { + overrides, err := loadToolsOverride(opts.ToolsPath) + if err != nil { + return ServerMetadata{}, err + } + tools, err := applyToolOverrides(metadata.Tools, overrides, opts.StrictMetadata) + if err != nil { + return ServerMetadata{}, err + } + metadata.Tools = tools + } + + if opts.PromptsPath != "" { + overrides, err := loadPromptsOverride(opts.PromptsPath) + if err != nil { + return ServerMetadata{}, err + } + prompts, err := applyPromptOverrides(metadata.Prompts, overrides, opts.StrictMetadata) + if err != nil { + return ServerMetadata{}, err + } + metadata.Prompts = prompts + } + + if opts.ExamplesPath != "" { + overrides, err := loadExamplesOverride(opts.ExamplesPath) + if err != nil { + return ServerMetadata{}, err + } + metadata.Examples = overrides + } + + return metadata, nil +} + +func (m ServerMetadata) ToolMap() map[string]ToolSpec { + result := make(map[string]ToolSpec, len(m.Tools)) + for _, tool := range m.Tools { + result[tool.Name] = tool + } + return result +} + +func (m ServerMetadata) PromptMap() map[string]PromptSpec { + result := make(map[string]PromptSpec, len(m.Prompts)) + for _, prompt := range m.Prompts { + result[prompt.Name] = prompt + } + return result +} + +func toolParamDescription(tool ToolSpec, name string) (string, error) { + for _, param := range tool.Params { + if param.Name == name { + return param.Description, nil + } + } + return "", fmt.Errorf("tool %q missing parameter metadata for %q", tool.Name, name) +} + +func promptArgSpec(prompt PromptSpec, name string) (PromptArgSpec, error) { + for _, arg := range prompt.Arguments { + if arg.Name == name { + return arg, nil + } + } + return PromptArgSpec{}, fmt.Errorf("prompt %q missing argument metadata for %q", prompt.Name, name) +} + +func requireToolSpec(toolSpecs map[string]ToolSpec, name string) (ToolSpec, error) { + if spec, ok := toolSpecs[name]; ok { + return spec, nil + } + return ToolSpec{}, fmt.Errorf("missing tool metadata for %q", name) +} + +func requirePromptSpec(promptSpecs map[string]PromptSpec, name string) (PromptSpec, error) { + if spec, ok := promptSpecs[name]; ok { + return spec, nil + } + return PromptSpec{}, fmt.Errorf("missing prompt metadata for %q", name) +} + +type toolOverride struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Parameters *toolOverrideParams `json:"parameters,omitempty"` +} + +type toolOverrideParams struct { + Properties map[string]toolOverrideProperty `json:"properties"` + Required *[]string `json:"required"` +} + +type toolOverrideProperty struct { + Description *string `json:"description,omitempty"` +} + +type promptOverride struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Arguments *[]promptArgOverride `json:"arguments,omitempty"` +} + +type promptArgOverride struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Required *bool `json:"required,omitempty"` +} + +func loadToolsOverride(path string) ([]toolOverride, error) { + data, err := readJSONFile(path) + if err != nil { + return nil, err + } + + var list []toolOverride + if err := json.Unmarshal(data, &list); err == nil && len(list) > 0 { + return list, nil + } + + var wrapper struct { + Tools []toolOverride `json:"tools"` + } + if err := json.Unmarshal(data, &wrapper); err == nil && len(wrapper.Tools) > 0 { + return wrapper.Tools, nil + } + + return nil, fmt.Errorf("tools override file %s must be a JSON array of tools or an object with a non-empty \"tools\" field", path) +} + +func loadPromptsOverride(path string) ([]promptOverride, error) { + data, err := readJSONFile(path) + if err != nil { + return nil, err + } + + var list []promptOverride + if err := json.Unmarshal(data, &list); err == nil && len(list) > 0 { + return list, nil + } + + var wrapper struct { + Prompts []promptOverride `json:"prompts"` + } + if err := json.Unmarshal(data, &wrapper); err == nil && len(wrapper.Prompts) > 0 { + return wrapper.Prompts, nil + } + + return nil, fmt.Errorf("prompts override file %s must be a JSON array of prompts or an object with a non-empty \"prompts\" field", path) +} + +func readJSONFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if len(bytes.TrimSpace(data)) == 0 { + return nil, fmt.Errorf("%s is empty", path) + } + return data, nil +} + +func applyToolOverrides(defaults []ToolSpec, overrides []toolOverride, strict bool) ([]ToolSpec, error) { + if len(overrides) == 0 { + return nil, fmt.Errorf("tools override file contains no tools") + } + + updated := make([]ToolSpec, len(defaults)) + copy(updated, defaults) + + defaultIndex := make(map[string]int, len(defaults)) + defaultMap := make(map[string]ToolSpec, len(defaults)) + for idx, tool := range defaults { + defaultIndex[tool.Name] = idx + defaultMap[tool.Name] = tool + } + + overrideMap := make(map[string]toolOverride, len(overrides)) + for _, tool := range overrides { + if tool.Name == "" { + return nil, fmt.Errorf("tools override contains a tool with no name") + } + if _, exists := overrideMap[tool.Name]; exists { + return nil, fmt.Errorf("tools override contains duplicate tool %q", tool.Name) + } + overrideMap[tool.Name] = tool + } + + for name := range overrideMap { + if _, ok := defaultMap[name]; !ok { + return nil, fmt.Errorf("tools override contains unknown tool %q", name) + } + } + + if strict { + for _, tool := range defaults { + if _, ok := overrideMap[tool.Name]; !ok { + return nil, fmt.Errorf("tools override missing tool %q", tool.Name) + } + } + } + + for _, override := range overrides { + idx := defaultIndex[override.Name] + tool := updated[idx] + + if override.Description != nil { + if strings.TrimSpace(*override.Description) == "" { + return nil, fmt.Errorf("tools override tool %q missing description", tool.Name) + } + tool.Description = *override.Description + } else if strict { + return nil, fmt.Errorf("tools override tool %q missing description", tool.Name) + } + + if override.Parameters != nil { + if override.Parameters.Required != nil { + if !stringSetEqual(toolRequiredNames(tool), *override.Parameters.Required) { + return nil, fmt.Errorf("tools override tool %q required parameters mismatch", tool.Name) + } + } + if override.Parameters.Properties != nil { + paramIndex := toolParamIndex(tool.Params) + for name, overrideParam := range override.Parameters.Properties { + paramIdx, ok := paramIndex[name] + if !ok { + return nil, fmt.Errorf("tools override tool %q contains unknown parameter %q", tool.Name, name) + } + if overrideParam.Description == nil || strings.TrimSpace(*overrideParam.Description) == "" { + return nil, fmt.Errorf("tools override tool %q parameter %q missing description", tool.Name, name) + } + param := tool.Params[paramIdx] + param.Description = *overrideParam.Description + tool.Params[paramIdx] = param + } + } + } else if strict && len(tool.Params) > 0 { + return nil, fmt.Errorf("tools override tool %q missing parameters", tool.Name) + } + + if strict { + if override.Parameters == nil || override.Parameters.Properties == nil { + return nil, fmt.Errorf("tools override tool %q missing parameters", tool.Name) + } + for _, param := range tool.Params { + if _, ok := override.Parameters.Properties[param.Name]; !ok { + return nil, fmt.Errorf("tools override tool %q missing parameter %q", tool.Name, param.Name) + } + } + } + + updated[idx] = tool + } + + return updated, nil +} + +func applyPromptOverrides(defaults []PromptSpec, overrides []promptOverride, strict bool) ([]PromptSpec, error) { + if len(overrides) == 0 { + return nil, fmt.Errorf("prompts override file contains no prompts") + } + + updated := make([]PromptSpec, len(defaults)) + copy(updated, defaults) + + defaultIndex := make(map[string]int, len(defaults)) + defaultMap := make(map[string]PromptSpec, len(defaults)) + for idx, prompt := range defaults { + defaultIndex[prompt.Name] = idx + defaultMap[prompt.Name] = prompt + } + + overrideMap := make(map[string]promptOverride, len(overrides)) + for _, prompt := range overrides { + if prompt.Name == "" { + return nil, fmt.Errorf("prompts override contains a prompt with no name") + } + if _, exists := overrideMap[prompt.Name]; exists { + return nil, fmt.Errorf("prompts override contains duplicate prompt %q", prompt.Name) + } + overrideMap[prompt.Name] = prompt + } + + for name := range overrideMap { + if _, ok := defaultMap[name]; !ok { + return nil, fmt.Errorf("prompts override contains unknown prompt %q", name) + } + } + + if strict { + for _, prompt := range defaults { + if _, ok := overrideMap[prompt.Name]; !ok { + return nil, fmt.Errorf("prompts override missing prompt %q", prompt.Name) + } + } + } + + for _, override := range overrides { + idx := defaultIndex[override.Name] + prompt := updated[idx] + + if override.Description != nil { + if strings.TrimSpace(*override.Description) == "" { + return nil, fmt.Errorf("prompts override prompt %q missing description", prompt.Name) + } + prompt.Description = *override.Description + } else if strict { + return nil, fmt.Errorf("prompts override prompt %q missing description", prompt.Name) + } + + if override.Arguments != nil { + argMap := make(map[string]promptArgOverride, len(*override.Arguments)) + for _, arg := range *override.Arguments { + if arg.Name == "" { + return nil, fmt.Errorf("prompts override prompt %q contains an argument with no name", prompt.Name) + } + if _, exists := argMap[arg.Name]; exists { + return nil, fmt.Errorf("prompts override prompt %q contains duplicate argument %q", prompt.Name, arg.Name) + } + argMap[arg.Name] = arg + } + + defaultArgNames := make(map[string]PromptArgSpec, len(prompt.Arguments)) + for _, arg := range prompt.Arguments { + defaultArgNames[arg.Name] = arg + } + + for name, overrideArg := range argMap { + defaultArg, ok := defaultArgNames[name] + if !ok { + return nil, fmt.Errorf("prompts override prompt %q contains unknown argument %q", prompt.Name, name) + } + if overrideArg.Description == nil || strings.TrimSpace(*overrideArg.Description) == "" { + return nil, fmt.Errorf("prompts override prompt %q argument %q missing description", prompt.Name, name) + } + if overrideArg.Required != nil && *overrideArg.Required != defaultArg.Required { + return nil, fmt.Errorf("prompts override prompt %q argument %q required mismatch", prompt.Name, name) + } + } + + for idx := range prompt.Arguments { + arg := prompt.Arguments[idx] + if overrideArg, ok := argMap[arg.Name]; ok { + arg.Description = *overrideArg.Description + prompt.Arguments[idx] = arg + } + } + } else if strict && len(prompt.Arguments) > 0 { + return nil, fmt.Errorf("prompts override prompt %q missing arguments", prompt.Name) + } + + if strict { + if override.Arguments == nil && len(prompt.Arguments) > 0 { + return nil, fmt.Errorf("prompts override prompt %q missing arguments", prompt.Name) + } + if override.Arguments != nil { + requiredArgs := make(map[string]bool, len(prompt.Arguments)) + for _, arg := range prompt.Arguments { + requiredArgs[arg.Name] = true + } + for _, arg := range *override.Arguments { + delete(requiredArgs, arg.Name) + } + for name := range requiredArgs { + return nil, fmt.Errorf("prompts override prompt %q missing argument %q", prompt.Name, name) + } + } + } + + updated[idx] = prompt + } + + return updated, nil +} + +func validateExamples(examples []ExampleSpec) error { + for idx, example := range examples { + if example.Prompt == "" { + return fmt.Errorf("examples override entry %d missing prompt", idx+1) + } + if example.Description == "" { + return fmt.Errorf("examples override entry %d missing description", idx+1) + } + } + return nil +} + +func toolRequiredNames(tool ToolSpec) []string { + names := make([]string, 0) + for _, param := range tool.Params { + if param.Required { + names = append(names, param.Name) + } + } + return names +} + +func toolParamIndex(params []ToolParamSpec) map[string]int { + index := make(map[string]int, len(params)) + for idx, param := range params { + index[param.Name] = idx + } + return index +} + +func stringSetEqual(a []string, b []string) bool { + if len(a) != len(b) { + return false + } + + counts := make(map[string]int, len(a)) + for _, name := range a { + counts[name]++ + } + for _, name := range b { + if counts[name] == 0 { + return false + } + counts[name]-- + } + for _, count := range counts { + if count != 0 { + return false + } + } + return true +} + +func loadExamplesOverride(path string) ([]ExampleSpec, error) { + examples, err := loadExamplesOverrideFile(path) + if err != nil { + return nil, err + } + if err := validateExamples(examples); err != nil { + return nil, err + } + return examples, nil +} + +func loadExamplesOverrideFile(path string) ([]ExampleSpec, error) { + data, err := readJSONFile(path) + if err != nil { + return nil, err + } + + var list []ExampleSpec + if err := json.Unmarshal(data, &list); err == nil && len(list) > 0 { + return list, nil + } + + var wrapper struct { + Examples []ExampleSpec `json:"examples"` + } + if err := json.Unmarshal(data, &wrapper); err == nil && len(wrapper.Examples) > 0 { + return wrapper.Examples, nil + } + + return nil, fmt.Errorf("examples override file %s must be a JSON array of examples or an object with a non-empty \"examples\" field", path) +} diff --git a/cmd/clippy/mcp/metadata_test.go b/cmd/clippy/mcp/metadata_test.go new file mode 100644 index 00000000..0e6ba394 --- /dev/null +++ b/cmd/clippy/mcp/metadata_test.go @@ -0,0 +1,139 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTempJSON(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "override.json") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write temp json: %v", err) + } + return path +} + +func findToolParam(tool ToolSpec, name string) (ToolParamSpec, bool) { + for _, param := range tool.Params { + if param.Name == name { + return param, true + } + } + return ToolParamSpec{}, false +} + +func TestDefaultServerMetadataLoads(t *testing.T) { + metadata, err := DefaultServerMetadata() + if err != nil { + t.Fatalf("DefaultServerMetadata: %v", err) + } + if len(metadata.Tools) == 0 { + t.Fatalf("expected default tools") + } + + toolMap := metadata.ToolMap() + copyTool, ok := toolMap["clipboard_copy"] + if !ok { + t.Fatalf("expected clipboard_copy tool") + } + if _, ok := findToolParam(copyTool, "text"); !ok { + t.Fatalf("expected clipboard_copy text param") + } +} + +func TestLoadServerMetadataPartialToolOverride(t *testing.T) { + override := `[ + { + "name": "clipboard_copy", + "parameters": { + "properties": { + "text": {"description": "New text description"} + } + } + } +]` + path := writeTempJSON(t, override) + + metadata, err := LoadServerMetadata(ServerOptions{ToolsPath: path}) + if err != nil { + t.Fatalf("LoadServerMetadata: %v", err) + } + + copyTool := metadata.ToolMap()["clipboard_copy"] + param, ok := findToolParam(copyTool, "text") + if !ok { + t.Fatalf("expected clipboard_copy text param") + } + if param.Description != "New text description" { + t.Fatalf("expected updated description, got %q", param.Description) + } +} + +func TestLoadServerMetadataStrictToolOverrideMissingParams(t *testing.T) { + override := `[ + { + "name": "clipboard_copy", + "description": "Only description" + } +]` + path := writeTempJSON(t, override) + + _, err := LoadServerMetadata(ServerOptions{ToolsPath: path, StrictMetadata: true}) + if err == nil { + t.Fatalf("expected strict metadata error") + } +} + +func TestLoadServerMetadataToolRequiredMismatch(t *testing.T) { + override := `[ + { + "name": "buffer_copy", + "parameters": { + "required": [] + } + } +]` + path := writeTempJSON(t, override) + + _, err := LoadServerMetadata(ServerOptions{ToolsPath: path}) + if err == nil { + t.Fatalf("expected required mismatch error") + } +} + +func TestLoadServerMetadataPartialPromptOverride(t *testing.T) { + override := `[ + { + "name": "copy-recent-download", + "description": "Copy recent downloads quickly" + } +]` + path := writeTempJSON(t, override) + + metadata, err := LoadServerMetadata(ServerOptions{PromptsPath: path}) + if err != nil { + t.Fatalf("LoadServerMetadata: %v", err) + } + + prompt := metadata.PromptMap()["copy-recent-download"] + if prompt.Description != "Copy recent downloads quickly" { + t.Fatalf("expected updated prompt description, got %q", prompt.Description) + } +} + +func TestLoadServerMetadataStrictPromptOverrideMissingArgs(t *testing.T) { + override := `[ + { + "name": "copy-recent-download", + "description": "Copy recent downloads quickly" + } +]` + path := writeTempJSON(t, override) + + _, err := LoadServerMetadata(ServerOptions{PromptsPath: path, StrictMetadata: true}) + if err == nil { + t.Fatalf("expected strict metadata error") + } +} diff --git a/cmd/clippy/mcp/server.go b/cmd/clippy/mcp/server.go index 67583727..dce317b0 100644 --- a/cmd/clippy/mcp/server.go +++ b/cmd/clippy/mcp/server.go @@ -95,8 +95,59 @@ type BufferResult struct { SourceRange string `json:"source_range,omitempty"` } -// StartServer starts the MCP server +// StartServer starts the MCP server. func StartServer() error { + return StartServerWithOptions(ServerOptions{}) +} + +// StartServerWithOptions starts the MCP server with optional metadata overrides. +func StartServerWithOptions(opts ServerOptions) error { + metadata, err := LoadServerMetadata(opts) + if err != nil { + return err + } + + toolSpecs := metadata.ToolMap() + promptSpecs := metadata.PromptMap() + + copySpec, err := requireToolSpec(toolSpecs, "clipboard_copy") + if err != nil { + return err + } + pasteSpec, err := requireToolSpec(toolSpecs, "clipboard_paste") + if err != nil { + return err + } + recentSpec, err := requireToolSpec(toolSpecs, "get_recent_downloads") + if err != nil { + return err + } + bufferCopySpec, err := requireToolSpec(toolSpecs, "buffer_copy") + if err != nil { + return err + } + bufferPasteSpec, err := requireToolSpec(toolSpecs, "buffer_paste") + if err != nil { + return err + } + bufferCutSpec, err := requireToolSpec(toolSpecs, "buffer_cut") + if err != nil { + return err + } + bufferListSpec, err := requireToolSpec(toolSpecs, "buffer_list") + if err != nil { + return err + } + + copyPromptSpec, err := requirePromptSpec(promptSpecs, "copy-recent-download") + if err != nil { + return err + } + pastePromptSpec, err := requirePromptSpec(promptSpecs, "paste-here") + if err != nil { + return err + } + // Create MCP server s := server.NewMCPServer( "Clippy MCP Server", @@ -110,12 +161,25 @@ func StartServer() error { } // Define copy tool + copyTextDesc, err := toolParamDescription(copySpec, "text") + if err != nil { + return err + } + copyFileDesc, err := toolParamDescription(copySpec, "file") + if err != nil { + return err + } + copyForceTextDesc, err := toolParamDescription(copySpec, "force_text") + if err != nil { + return err + } + copyTool := mcp.NewTool( "clipboard_copy", - mcp.WithDescription("Copy text or file to clipboard. CRITICAL: Use 'text' parameter for ANY generated content, code, messages, or text that will be pasted. Use 'file' parameter ONLY for existing files that need to be attached/uploaded. DEFAULT TO 'text' FOR ALL GENERATED CONTENT. PRO TIP: For iterative editing, write to a temp file then use file + force_text='true' to avoid regenerating full content each time."), - mcp.WithString("text", mcp.Description("Text content to copy - USE THIS for all generated content, code snippets, messages, emails, documentation, or any text that will be pasted")), - mcp.WithString("file", mcp.Description("File path to copy as file reference - ONLY use this for existing files on disk that need to be dragged/attached, NOT for generated content. PRO TIP: Use with force_text='true' for efficient iterative editing of temp files.")), - mcp.WithString("force_text", mcp.Description("Set to 'true' to force copying file content as text (only with 'file' parameter). USEFUL PATTERN: Write code to /tmp/script.ext, edit incrementally with Edit tool, then copy with file='/tmp/script.ext' force_text='true' for efficient iterative development without regenerating full text.")), + mcp.WithDescription(copySpec.Description), + mcp.WithString("text", mcp.Description(copyTextDesc)), + mcp.WithString("file", mcp.Description(copyFileDesc)), + mcp.WithString("force_text", mcp.Description(copyForceTextDesc)), ) // Add copy tool handler @@ -195,10 +259,15 @@ func StartServer() error { }) // Define paste tool + pasteDestDesc, err := toolParamDescription(pasteSpec, "destination") + if err != nil { + return err + } + pasteTool := mcp.NewTool( "clipboard_paste", - mcp.WithDescription("Paste clipboard content to file or directory. Intelligently handles both text content and file references from clipboard."), - mcp.WithString("destination", mcp.Description("Destination directory (defaults to current directory)")), + mcp.WithDescription(pasteSpec.Description), + mcp.WithString("destination", mcp.Description(pasteDestDesc)), ) // Add paste tool handler @@ -273,11 +342,20 @@ func StartServer() error { }) // Define recent downloads tool + recentCountDesc, err := toolParamDescription(recentSpec, "count") + if err != nil { + return err + } + recentDurationDesc, err := toolParamDescription(recentSpec, "duration") + if err != nil { + return err + } + recentTool := mcp.NewTool( "get_recent_downloads", - mcp.WithDescription("Get list of recently added files from Downloads, Desktop, and Documents folders. Only shows files that were recently added to these directories."), - mcp.WithNumber("count", mcp.Description("Number of files to return (default: 10)")), - mcp.WithString("duration", mcp.Description("Time duration to look back (e.g. 5m, 1h, 7d, 2 weeks ago, yesterday)")), + mcp.WithDescription(recentSpec.Description), + mcp.WithNumber("count", mcp.Description(recentCountDesc)), + mcp.WithString("duration", mcp.Description(recentDurationDesc)), ) // Add recent downloads tool handler @@ -330,12 +408,25 @@ func StartServer() error { }) // Define buffer_copy tool + bufferCopyFileDesc, err := toolParamDescription(bufferCopySpec, "file") + if err != nil { + return err + } + bufferCopyStartDesc, err := toolParamDescription(bufferCopySpec, "start_line") + if err != nil { + return err + } + bufferCopyEndDesc, err := toolParamDescription(bufferCopySpec, "end_line") + if err != nil { + return err + } + bufferCopyTool := mcp.NewTool( "buffer_copy", - mcp.WithDescription("Copy file bytes (with optional line ranges) to agent's private buffer for refactoring. Server reads bytes directly - no token generation. Use when moving code between files or reorganizing large sections. Better than Edit for large blocks since content isn't regenerated."), - mcp.WithString("file", mcp.Description("File path to copy from (required)"), mcp.Required()), - mcp.WithNumber("start_line", mcp.Description("Starting line number (1-indexed, omit for entire file)")), - mcp.WithNumber("end_line", mcp.Description("Ending line number (inclusive, omit for entire file)")), + mcp.WithDescription(bufferCopySpec.Description), + mcp.WithString("file", mcp.Description(bufferCopyFileDesc), mcp.Required()), + mcp.WithNumber("start_line", mcp.Description(bufferCopyStartDesc)), + mcp.WithNumber("end_line", mcp.Description(bufferCopyEndDesc)), ) // Add buffer_copy tool handler @@ -412,13 +503,30 @@ func StartServer() error { }) // Define buffer_paste tool + bufferPasteFileDesc, err := toolParamDescription(bufferPasteSpec, "file") + if err != nil { + return err + } + bufferPasteModeDesc, err := toolParamDescription(bufferPasteSpec, "mode") + if err != nil { + return err + } + bufferPasteAtDesc, err := toolParamDescription(bufferPasteSpec, "at_line") + if err != nil { + return err + } + bufferPasteToDesc, err := toolParamDescription(bufferPasteSpec, "to_line") + if err != nil { + return err + } + bufferPasteTool := mcp.NewTool( "buffer_paste", - mcp.WithDescription("Paste buffered bytes to file with append/insert/replace modes. Use after buffer_copy to complete refactoring. Writes exact bytes without token generation. append=add to end, insert=inject at line, replace=overwrite range. Byte-perfect, no content regeneration."), - mcp.WithString("file", mcp.Description("Target file path (required)"), mcp.Required()), - mcp.WithString("mode", mcp.Description("Paste mode: 'append' (default), 'insert', or 'replace'")), - mcp.WithNumber("at_line", mcp.Description("Line number for insert/replace mode (1-indexed)")), - mcp.WithNumber("to_line", mcp.Description("End line for replace mode (inclusive, required for replace)")), + mcp.WithDescription(bufferPasteSpec.Description), + mcp.WithString("file", mcp.Description(bufferPasteFileDesc), mcp.Required()), + mcp.WithString("mode", mcp.Description(bufferPasteModeDesc)), + mcp.WithNumber("at_line", mcp.Description(bufferPasteAtDesc)), + mcp.WithNumber("to_line", mcp.Description(bufferPasteToDesc)), ) // Add buffer_paste tool handler @@ -528,12 +636,25 @@ func StartServer() error { }) // Define buffer_cut tool + bufferCutFileDesc, err := toolParamDescription(bufferCutSpec, "file") + if err != nil { + return err + } + bufferCutStartDesc, err := toolParamDescription(bufferCutSpec, "start_line") + if err != nil { + return err + } + bufferCutEndDesc, err := toolParamDescription(bufferCutSpec, "end_line") + if err != nil { + return err + } + bufferCutTool := mcp.NewTool( "buffer_cut", - mcp.WithDescription("Cut lines from file to buffer - copies to buffer then deletes from source. Like buffer_copy but removes the lines after copying. Use for moving code sections without manual deletion. Atomic operation - only deletes if copy succeeds."), - mcp.WithString("file", mcp.Description("File path to cut from (required)"), mcp.Required()), - mcp.WithNumber("start_line", mcp.Description("Starting line number (1-indexed, omit for entire file)")), - mcp.WithNumber("end_line", mcp.Description("Ending line number (inclusive, omit for entire file)")), + mcp.WithDescription(bufferCutSpec.Description), + mcp.WithString("file", mcp.Description(bufferCutFileDesc), mcp.Required()), + mcp.WithNumber("start_line", mcp.Description(bufferCutStartDesc)), + mcp.WithNumber("end_line", mcp.Description(bufferCutEndDesc)), ) // Add buffer_cut tool handler @@ -627,7 +748,7 @@ func StartServer() error { // Define buffer_list tool bufferListTool := mcp.NewTool( "buffer_list", - mcp.WithDescription("Show buffer metadata (lines, source file, range). Use to verify buffer contents before pasting. Returns metadata only, not actual content."), + mcp.WithDescription(bufferListSpec.Description), ) // Add buffer_list tool handler @@ -664,10 +785,21 @@ func StartServer() error { }) // Add prompts for common operations + copyPromptArg, err := promptArgSpec(copyPromptSpec, "count") + if err != nil { + return err + } + copyPromptArgOptions := []mcp.ArgumentOption{ + mcp.ArgumentDescription(copyPromptArg.Description), + } + if copyPromptArg.Required { + copyPromptArgOptions = append(copyPromptArgOptions, mcp.RequiredArgument()) + } + s.AddPrompt(mcp.NewPrompt( "copy-recent-download", - mcp.WithPromptDescription("Copy the most recent download to clipboard"), - mcp.WithArgument("count"), + mcp.WithPromptDescription(copyPromptSpec.Description), + mcp.WithArgument("count", copyPromptArgOptions...), ), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { count := "1" if val, ok := request.Params.Arguments["count"]; ok { @@ -689,7 +821,7 @@ func StartServer() error { s.AddPrompt(mcp.NewPrompt( "paste-here", - mcp.WithPromptDescription("Paste clipboard content to current directory"), + mcp.WithPromptDescription(pastePromptSpec.Description), ), func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { return &mcp.GetPromptResult{ Messages: []mcp.PromptMessage{ diff --git a/examples/clippy.mcp.examples.json b/examples/clippy.mcp.examples.json new file mode 100644 index 00000000..c879085a --- /dev/null +++ b/examples/clippy.mcp.examples.json @@ -0,0 +1,18 @@ +[ + { + "prompt": "Generate a short meeting recap and copy it to my clipboard", + "description": "Create a concise recap ready to paste into chat or email" + }, + { + "prompt": "Write a bash script that cleans temp files and copy it", + "description": "Produce a script and place it directly on the clipboard" + }, + { + "prompt": "Copy the three most recent downloads to the clipboard", + "description": "Quickly grab multiple recent files" + }, + { + "prompt": "Move the parseConfig function into its own file", + "description": "Use buffer_copy and buffer_paste for byte-accurate refactors" + } +] diff --git a/examples/clippy.mcp.prompts.json b/examples/clippy.mcp.prompts.json new file mode 100644 index 00000000..67e24792 --- /dev/null +++ b/examples/clippy.mcp.prompts.json @@ -0,0 +1,17 @@ +[ + { + "name": "copy-recent-download", + "description": "Copy a recent download straight to the clipboard", + "arguments": [ + { + "name": "count", + "description": "How many recent downloads to copy", + "required": false + } + ] + }, + { + "name": "paste-here", + "description": "Paste clipboard content into the current directory" + } +] diff --git a/examples/clippy.mcp.tools.json b/examples/clippy.mcp.tools.json new file mode 100644 index 00000000..c425bbed --- /dev/null +++ b/examples/clippy.mcp.tools.json @@ -0,0 +1,131 @@ +[ + { + "name": "clipboard_copy", + "description": "Copy text or existing files to the clipboard. Prefer text for any generated output.", + "parameters": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Generated text to copy (preferred for any newly created content)" + }, + "file": { + "type": "string", + "description": "Path to an existing file to copy as a file reference" + }, + "force_text": { + "type": "string", + "description": "Set to 'true' to copy file contents as text" + } + } + } + }, + { + "name": "clipboard_paste", + "description": "Paste clipboard content to a directory or file.", + "parameters": { + "type": "object", + "properties": { + "destination": { + "type": "string", + "description": "Target directory for pasted files (defaults to current directory)" + } + } + } + }, + { + "name": "get_recent_downloads", + "description": "List recently added files from Downloads, Desktop, and Documents.", + "parameters": { + "type": "object", + "properties": { + "count": { + "type": "number", + "description": "Maximum number of files to return" + }, + "duration": { + "type": "string", + "description": "How far back to look (e.g. 10m, 2h, 3d)" + } + } + } + }, + { + "name": "buffer_copy", + "description": "Copy file bytes into the agent buffer for safe refactors.", + "parameters": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Source file path (required)" + }, + "start_line": { + "type": "number", + "description": "Start line (1-indexed, optional)" + }, + "end_line": { + "type": "number", + "description": "End line (inclusive, optional)" + } + }, + "required": ["file"] + } + }, + { + "name": "buffer_cut", + "description": "Cut lines from a file into the agent buffer and remove them from the source.", + "parameters": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Source file path (required)" + }, + "start_line": { + "type": "number", + "description": "Start line (1-indexed, optional)" + }, + "end_line": { + "type": "number", + "description": "End line (inclusive, optional)" + } + }, + "required": ["file"] + } + }, + { + "name": "buffer_paste", + "description": "Paste buffered bytes into a file with append/insert/replace modes.", + "parameters": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "Target file path (required)" + }, + "mode": { + "type": "string", + "description": "Paste mode: append, insert, or replace" + }, + "at_line": { + "type": "number", + "description": "Line number for insert/replace (1-indexed)" + }, + "to_line": { + "type": "number", + "description": "End line for replace (inclusive)" + } + }, + "required": ["file"] + } + }, + { + "name": "buffer_list", + "description": "Show buffer metadata (line count, source file, and range).", + "parameters": { + "type": "object", + "properties": {} + } + } +] diff --git a/server.json b/server.json index 850f5261..6ee5f680 100644 --- a/server.json +++ b/server.json @@ -89,6 +89,28 @@ "required": ["file"] } }, + { + "name": "buffer_cut", + "description": "Cut lines from file to buffer - copies to buffer then deletes from source. Like buffer_copy but removes the lines after copying. Use for moving code sections without manual deletion. Atomic operation - only deletes if copy succeeds.", + "parameters": { + "type": "object", + "properties": { + "file": { + "type": "string", + "description": "File path to cut from (required)" + }, + "start_line": { + "type": "number", + "description": "Starting line number (1-indexed, omit for entire file)" + }, + "end_line": { + "type": "number", + "description": "Ending line number (inclusive, omit for entire file)" + } + }, + "required": ["file"] + } + }, { "name": "buffer_paste", "description": "Paste file bytes from agent's buffer to file. Writes exact bytes without agent token generation. Supports append/insert/replace modes for surgical refactoring.", diff --git a/server_metadata.go b/server_metadata.go new file mode 100644 index 00000000..9558e498 --- /dev/null +++ b/server_metadata.go @@ -0,0 +1,8 @@ +package clippy + +import _ "embed" + +// DefaultServerJSON contains the MCP server metadata defaults. +// +//go:embed server.json +var DefaultServerJSON []byte