├── .github ├── FUNDING.yml ├── workflows │ ├── pull-request.yaml │ ├── issue-open.yaml │ ├── smoke-test.yaml │ ├── pull-request-open.yaml │ └── release.yaml └── pull_request_template.md ├── logo.png ├── internal ├── schema │ ├── doc.go │ └── cache.go ├── bridge │ └── flags │ │ ├── doc.go │ │ ├── flags.go │ │ └── default.go └── cfgmgr │ ├── manager │ ├── claude │ │ ├── claude.go │ │ ├── claude_darwin.go │ │ ├── doc.go │ │ ├── claude_windows.go │ │ ├── claude_linux.go │ │ ├── server.go │ │ ├── config.go │ │ └── config_test.go │ ├── doc.go │ ├── cursor │ │ ├── cursor_darwin.go │ │ ├── cursor_linux.go │ │ ├── cursor_windows.go │ │ ├── doc.go │ │ ├── cursor.go │ │ ├── server.go │ │ ├── config.go │ │ └── config_test.go │ ├── vscode │ │ ├── vscode_linux.go │ │ ├── vscode_windows.go │ │ ├── vscode_darwin.go │ │ ├── doc.go │ │ ├── vscode.go │ │ ├── server.go │ │ ├── config.go │ │ └── config_test.go │ ├── utils.go │ ├── utils_test.go │ ├── manager.go │ └── manager_test.go │ └── cmd │ ├── cursor │ ├── root.go │ ├── doc.go │ ├── list.go │ ├── disable.go │ └── enable.go │ ├── vscode │ ├── root.go │ ├── doc.go │ ├── list.go │ ├── disable.go │ └── enable.go │ └── claude │ ├── root.go │ ├── doc.go │ ├── list.go │ ├── disable.go │ └── enable.go ├── examples ├── argocd │ ├── doc.go │ ├── makefile │ ├── main_test.go │ └── main.go ├── helm │ ├── doc.go │ ├── makefile │ ├── main_test.go │ ├── config.go │ ├── main.go │ └── go.mod ├── kubectl │ ├── doc.go │ ├── main_test.go │ ├── makefile │ ├── main.go │ └── go.mod ├── make │ ├── main_test.go │ ├── doc.go │ ├── makefile │ ├── go.mod │ ├── main.go │ └── go.sum └── README.md ├── test ├── doc.go ├── tools_test.go └── tools.go ├── .golangci.yml ├── utils.go ├── CONTRIBUTING.md ├── utils_test.go ├── .gitignore ├── root.go ├── schema.go ├── go.mod ├── makefile ├── start.go ├── config_test.go ├── docs ├── execution.md ├── schema.md └── config.md ├── stream.go ├── tools.go ├── selectors.go ├── execute.go ├── doc.go ├── go.sum ├── README.md ├── magefile.go ├── config.go ├── selector.go ├── execute_test.go ├── LICENSE └── selector_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: njayp 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/njayp/ophis/HEAD/logo.png -------------------------------------------------------------------------------- /internal/schema/doc.go: -------------------------------------------------------------------------------- 1 | // Package schema provides JSON schema caching utilities. 2 | package schema 3 | -------------------------------------------------------------------------------- /internal/bridge/flags/doc.go: -------------------------------------------------------------------------------- 1 | // Package flags provides JSON schema generation from Cobra command flags. 2 | package flags 3 | -------------------------------------------------------------------------------- /examples/argocd/doc.go: -------------------------------------------------------------------------------- 1 | // Package main implements a wrapper around the ArgoCD CLI to add Ophis functionality. 2 | package main 3 | -------------------------------------------------------------------------------- /examples/helm/doc.go: -------------------------------------------------------------------------------- 1 | // Package main shows how easy it is to turn a helm into an 2 | // MCP server using njayp/ophis. 3 | package main 4 | -------------------------------------------------------------------------------- /examples/kubectl/doc.go: -------------------------------------------------------------------------------- 1 | // Package main shows how easy it is to turn kubectl into an 2 | // MCP server using njayp/ophis. 3 | package main 4 | -------------------------------------------------------------------------------- /test/doc.go: -------------------------------------------------------------------------------- 1 | // Package test provides integration testing helper functions for ophis 2 | // It assumes ophis.Command is a root level subcommand 3 | package test 4 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/claude.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | // ConfigPath returns the default config path 4 | func ConfigPath() string { 5 | return getDefaultClaudeConfigPath() 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yaml: -------------------------------------------------------------------------------- 1 | name: pull request 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | smoke-test: 9 | uses: ./.github/workflows/smoke-test.yaml 10 | -------------------------------------------------------------------------------- /examples/make/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/njayp/ophis/test" 7 | ) 8 | 9 | func TestTools(t *testing.T) { 10 | test.Tools(t, makeCmd(), "make") 11 | } 12 | -------------------------------------------------------------------------------- /examples/make/doc.go: -------------------------------------------------------------------------------- 1 | // Package main provides an example MCP server that exposes make commands. 2 | // This demonstrates how to use njayp/ophis to turn a simple, home-made app into 3 | // a mcp server. 4 | package main 5 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## What does this PR do? 2 | 3 | Explain what this change does and why it’s needed. 4 | 5 | ## Related Issues 6 | 7 | Closes #123 8 | 9 | ## Checklist 10 | 11 | - [ ] Tests added or updated 12 | - [ ] Docs added or updated -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | enable: 5 | - govet 6 | - errcheck 7 | - staticcheck 8 | - unused 9 | - ineffassign 10 | - revive 11 | 12 | formatters: 13 | enable: 14 | - gci 15 | - gofmt 16 | - gofumpt 17 | - goimports 18 | -------------------------------------------------------------------------------- /examples/kubectl/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/njayp/ophis/test" 7 | ) 8 | 9 | func TestTools(t *testing.T) { 10 | cmd := rootCmd() 11 | 12 | test.Tools(t, cmd, 13 | "kubectl_get", 14 | "kubectl_describe", 15 | "kubectl_logs", 16 | "kubectl_explain", 17 | "kubectl_api-resources", 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /examples/helm/makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: up lint test build 3 | 4 | .PHONY: up 5 | up: 6 | go get -u ./... 7 | go mod tidy 8 | 9 | .PHONY: lint 10 | lint: 11 | golangci-lint fmt ./... 12 | golangci-lint run --allow-parallel-runners ./... 13 | 14 | .PHONY: test 15 | test: 16 | go test ./... 17 | 18 | .PHONY: build 19 | build: 20 | go build -o ../../bin/ ./... -------------------------------------------------------------------------------- /examples/make/makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: up lint test build 3 | 4 | .PHONY: up 5 | up: 6 | go get -u ./... 7 | go mod tidy 8 | 9 | .PHONY: lint 10 | lint: 11 | golangci-lint fmt ./... 12 | golangci-lint run --allow-parallel-runners ./... 13 | 14 | .PHONY: test 15 | test: 16 | go test ./... 17 | 18 | .PHONY: build 19 | build: 20 | go build -o ../../bin/ ./... -------------------------------------------------------------------------------- /examples/kubectl/makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: up lint test build 3 | 4 | .PHONY: up 5 | up: 6 | go get -u ./... 7 | go mod tidy 8 | 9 | .PHONY: lint 10 | lint: 11 | golangci-lint fmt ./... 12 | golangci-lint run --allow-parallel-runners ./... 13 | 14 | .PHONY: test 15 | test: 16 | go test ./... 17 | 18 | .PHONY: build 19 | build: 20 | go build -o ../../bin/ ./... -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Run `make build` to build all examples to `ophis/bin`. 4 | 5 | ### kubectl, helm, and argocd 6 | 7 | These examples show how easy it is to turn a complex CLI into an MCP server using njayp/ophis. Read-only commands were preferred, and global flags were mostly removed to lessen the context footprint. 8 | 9 | ### make 10 | 11 | The `make` example is a basic example that turns a small app into an MCP server. -------------------------------------------------------------------------------- /examples/argocd/makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: up lint test build 3 | 4 | .PHONY: up 5 | up: 6 | # do not update all, breaks builds 7 | # go get -u ./... 8 | go get -u github.com/argoproj/argo-cd/v3 9 | go mod tidy 10 | 11 | .PHONY: lint 12 | lint: 13 | golangci-lint fmt ./... 14 | golangci-lint run --allow-parallel-runners ./... 15 | 16 | .PHONY: test 17 | test: 18 | go test ./... 19 | 20 | .PHONY: build 21 | build: 22 | go build -o ../../bin/ ./... -------------------------------------------------------------------------------- /internal/cfgmgr/manager/doc.go: -------------------------------------------------------------------------------- 1 | // Package manager provides configuration management for MCP servers across platforms. 2 | // 3 | // This package contains shared functionality for managing MCP server configurations 4 | // for Claude Desktop and VSCode. It provides generic configuration management, 5 | // loading and saving JSON config files, and common utilities. 6 | // 7 | // This is an internal package and should not be imported by users of the ophis library. 8 | package manager 9 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/cursor/root.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // Command creates a new Cobra command for managing Cursor MCP servers. 8 | func Command() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "cursor", 11 | Short: "Manage Cursor MCP servers", 12 | Long: "Manage MCP server configuration for Cursor", 13 | } 14 | 15 | // Add subcommands 16 | cmd.AddCommand(enableCommand(), disableCommand(), listCommand()) 17 | return cmd 18 | } 19 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/vscode/root.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // Command creates a new Cobra command for managing VSCode MCP servers. 8 | func Command() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "vscode", 11 | Short: "Manage VSCode MCP servers", 12 | Long: "Manage MCP server configuration for Visual Studio Code", 13 | } 14 | 15 | // Add subcommands 16 | cmd.AddCommand(enableCommand(), disableCommand(), listCommand()) 17 | return cmd 18 | } 19 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/cursor_darwin.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultCursorUserConfigPath returns the default Cursor user mcp.json path on macOS 9 | func getDefaultCursorUserConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback to a reasonable default 13 | return filepath.Join("/Users", os.Getenv("USER"), ".cursor", "mcp.json") 14 | } 15 | return filepath.Join(homeDir, ".cursor", "mcp.json") 16 | } 17 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/claude/root.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | // Command creates a new Cobra command for managing Claude Desktop MCP servers. 8 | func Command() *cobra.Command { 9 | cmd := &cobra.Command{ 10 | Use: "claude", 11 | Short: "Manage Claude Desktop MCP servers", 12 | Long: "Manage MCP server configuration for Claude Desktop", 13 | } 14 | 15 | // Add subcommands 16 | cmd.AddCommand(enableCommand(), disableCommand(), listCommand()) 17 | return cmd 18 | } 19 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/cursor_linux.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultCursorUserConfigPath returns the default Cursor user mcp.json path on Linux 9 | func getDefaultCursorUserConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback using USER environment variable 13 | return filepath.Join("/home", os.Getenv("USER"), ".cursor", "mcp.json") 14 | } 15 | return filepath.Join(homeDir, ".cursor", "mcp.json") 16 | } 17 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/cursor_windows.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultCursorUserConfigPath returns the default Cursor user mcp.json path on Windows 9 | func getDefaultCursorUserConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback using USERPROFILE environment variable 13 | return filepath.Join(os.Getenv("USERPROFILE"), ".cursor", "mcp.json") 14 | } 15 | return filepath.Join(homeDir, ".cursor", "mcp.json") 16 | } 17 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/vscode_linux.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultVSCodeUserConfigPath returns the default VSCode user mcp.json path on Linux 9 | func getDefaultVSCodeUserConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback using USER environment variable 13 | return filepath.Join("/home", os.Getenv("USER"), ".config", "Code", "User", "mcp.json") 14 | } 15 | return filepath.Join(homeDir, ".config", "Code", "User", "mcp.json") 16 | } 17 | -------------------------------------------------------------------------------- /examples/argocd/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/njayp/ophis/test" 7 | ) 8 | 9 | func Test_main(t *testing.T) { 10 | test.Tools(t, rootCmd(), 11 | "argocd_app_get", 12 | "argocd_app_list", 13 | "argocd_app_diff", 14 | "argocd_app_manifests", 15 | "argocd_app_history", 16 | "argocd_app_resources", 17 | "argocd_app_logs", 18 | "argocd_app_sync", 19 | "argocd_app_wait", 20 | "argocd_app_rollback", 21 | "argocd_cluster_list", 22 | "argocd_proj_list", 23 | "argocd_repo_list", 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/vscode_windows.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultVSCodeUserConfigPath returns the default VSCode user mcp.json path on Windows 9 | func getDefaultVSCodeUserConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback using USERPROFILE environment variable 13 | return filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Roaming", "Code", "User", "mcp.json") 14 | } 15 | return filepath.Join(homeDir, "AppData", "Roaming", "Code", "User", "mcp.json") 16 | } 17 | -------------------------------------------------------------------------------- /examples/make/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/njayp/ophis/examples/make 2 | 3 | go 1.24.6 4 | 5 | replace github.com/njayp/ophis => ../../ 6 | 7 | require ( 8 | github.com/njayp/ophis v1.0.9 9 | github.com/spf13/cobra v1.10.2 10 | ) 11 | 12 | require ( 13 | github.com/google/jsonschema-go v0.3.0 // indirect 14 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 15 | github.com/modelcontextprotocol/go-sdk v1.1.0 // indirect 16 | github.com/spf13/pflag v1.0.10 // indirect 17 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 18 | golang.org/x/oauth2 v0.33.0 // indirect 19 | ) 20 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/claude/doc.go: -------------------------------------------------------------------------------- 1 | // Package claude provides CLI commands for managing Claude Desktop MCP servers. 2 | // 3 | // This package implements the 'mcp claude' subcommands: 4 | // - enable: Add MCP server to Claude Desktop configuration 5 | // - disable: Remove MCP server from Claude Desktop configuration 6 | // - list: Show all configured MCP servers 7 | // 8 | // This is an internal package and should not be imported directly by users of the ophis library. 9 | // These commands are automatically available when using ophis.Command() in your application. 10 | package claude 11 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/vscode_darwin.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultVSCodeUserConfigPath returns the default VSCode user mcp.json path on macOS 9 | func getDefaultVSCodeUserConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback to a reasonable default 13 | return filepath.Join("/Users", os.Getenv("USER"), "Library", "Application Support", "Code", "User", "mcp.json") 14 | } 15 | return filepath.Join(homeDir, "Library", "Application Support", "Code", "User", "mcp.json") 16 | } 17 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/claude_darwin.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultClaudeConfigPath returns the default Claude config path on macOS 9 | func getDefaultClaudeConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback to a reasonable default 13 | return filepath.Join("/Users", os.Getenv("USER"), "Library", "Application Support", "Claude", "claude_desktop_config.json") 14 | } 15 | return filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json") 16 | } 17 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/doc.go: -------------------------------------------------------------------------------- 1 | // Package claude provides configuration management for Claude Desktop MCP servers. 2 | // 3 | // This package handles: 4 | // - Claude Desktop configuration structure (claude_desktop_config.json) 5 | // - Platform-specific configuration file paths (macOS, Linux, Windows) 6 | // - MCP server entry management 7 | // 8 | // Platform-specific path functions use build tags to locate the Claude Desktop 9 | // configuration directory on different operating systems. 10 | // 11 | // This is an internal package and should not be imported by users of the ophis library. 12 | package claude 13 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/doc.go: -------------------------------------------------------------------------------- 1 | // Package cursor provides configuration management for Cursor MCP servers. 2 | // 3 | // This package handles: 4 | // - Cursor workspace configuration (.cursor/mcp.json) 5 | // - Cursor user-level configuration 6 | // - Platform-specific configuration file paths (macOS, Linux, Windows) 7 | // - MCP server entry management 8 | // 9 | // Platform-specific path functions use build tags to locate Cursor 10 | // configuration directories on different operating systems. 11 | // 12 | // This is an internal package and should not be imported by users of the ophis library. 13 | package cursor 14 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/doc.go: -------------------------------------------------------------------------------- 1 | // Package vscode provides configuration management for VSCode MCP servers. 2 | // 3 | // This package handles: 4 | // - VSCode workspace configuration (.vscode/mcp.json) 5 | // - VSCode user-level configuration 6 | // - Platform-specific configuration file paths (macOS, Linux, Windows) 7 | // - MCP server entry management 8 | // 9 | // Platform-specific path functions use build tags to locate VSCode 10 | // configuration directories on different operating systems. 11 | // 12 | // This is an internal package and should not be imported by users of the ophis library. 13 | package vscode 14 | -------------------------------------------------------------------------------- /examples/helm/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/njayp/ophis/test" 7 | ) 8 | 9 | func TestTools(t *testing.T) { 10 | cmd := rootCmd() 11 | 12 | test.Tools(t, cmd, 13 | "helm_get_hooks", 14 | "helm_get_manifest", 15 | "helm_get_notes", 16 | "helm_get_values", 17 | "helm_history", 18 | "helm_list", 19 | "helm_search_hub", 20 | "helm_search_repo", 21 | "helm_show_chart", 22 | "helm_show_crds", 23 | "helm_show_readme", 24 | "helm_show_values", 25 | "helm_status", 26 | "helm_repo_list", 27 | "helm_template", 28 | "helm_dependency_list", 29 | "helm_lint", 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/cursor/doc.go: -------------------------------------------------------------------------------- 1 | // Package cursor provides CLI commands for managing Cursor MCP servers. 2 | // 3 | // This package implements the 'mcp cursor' subcommands: 4 | // - enable: Add MCP server to Cursor configuration 5 | // - disable: Remove MCP server from Cursor configuration 6 | // - list: Show all configured MCP servers 7 | // 8 | // Supports both workspace (.cursor/mcp.json) and user-level configurations. 9 | // 10 | // This is an internal package and should not be imported directly by users of the ophis library. 11 | // These commands are automatically available when using ophis.Command() in your application. 12 | package cursor 13 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/vscode/doc.go: -------------------------------------------------------------------------------- 1 | // Package vscode provides CLI commands for managing VSCode MCP servers. 2 | // 3 | // This package implements the 'mcp vscode' subcommands: 4 | // - enable: Add MCP server to VSCode configuration 5 | // - disable: Remove MCP server from VSCode configuration 6 | // - list: Show all configured MCP servers 7 | // 8 | // Supports both workspace (.vscode/mcp.json) and user-level configurations. 9 | // 10 | // This is an internal package and should not be imported directly by users of the ophis library. 11 | // These commands are automatically available when using ophis.Command() in your application. 12 | package vscode 13 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "log/slog" 5 | "strings" 6 | ) 7 | 8 | // parseLogLevel converts a string log level to slog.Level. 9 | // Supported levels are: debug, info, warn, error (case-insensitive). 10 | // Defaults to info for unknown levels. 11 | func parseLogLevel(level string) slog.Level { 12 | // Parse log level 13 | slogLevel := slog.LevelInfo 14 | switch strings.ToLower(level) { 15 | case "debug": 16 | slogLevel = slog.LevelDebug 17 | case "info": 18 | slogLevel = slog.LevelInfo 19 | case "warn": 20 | slogLevel = slog.LevelWarn 21 | case "error": 22 | slogLevel = slog.LevelError 23 | } 24 | 25 | return slogLevel 26 | } 27 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/claude_windows.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultClaudeConfigPath returns the default Claude config path on Windows 9 | func getDefaultClaudeConfigPath() string { 10 | appData := os.Getenv("APPDATA") 11 | if appData == "" { 12 | // Fallback to a reasonable default 13 | userProfile := os.Getenv("USERPROFILE") 14 | if userProfile != "" { 15 | appData = filepath.Join(userProfile, "AppData", "Roaming") 16 | } else { 17 | appData = "C:\\Users\\Default\\AppData\\Roaming" 18 | } 19 | } 20 | return filepath.Join(appData, "Claude", "claude_desktop_config.json") 21 | } 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### Thank you for contributing to Ophis! Your efforts help make this project better for everyone. 2 | 3 | ## How to Contribute 4 | 5 | 1. Fork the repository on GitHub 6 | 2. Push changes to your fork and submit a pull request 7 | 8 | ## Development Setup 9 | 10 | ### Prerequisites 11 | 12 | - Go 1.24 or later 13 | - golangci-lint (for linting) 14 | - Make (for build automation) 15 | - Mage (for build automation) 16 | 17 | ### Setup Steps 18 | 19 | 1. **Clone the repository** 20 | ```bash 21 | git clone https://github.com/njayp/ophis.git 22 | cd ophis 23 | ``` 24 | 25 | 2. **Run dependencies, tests, linter, and builder** 26 | ```bash 27 | make 28 | ``` 29 | 30 | 31 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/claude_linux.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // getDefaultClaudeConfigPath returns the default Claude config path on Linux 9 | func getDefaultClaudeConfigPath() string { 10 | homeDir, err := os.UserHomeDir() 11 | if err != nil { 12 | // Fallback to a reasonable default 13 | return filepath.Join("/home", os.Getenv("USER"), ".config", "Claude", "claude_desktop_config.json") 14 | } 15 | 16 | // Check for XDG_CONFIG_HOME first 17 | configDir := os.Getenv("XDG_CONFIG_HOME") 18 | if configDir == "" { 19 | configDir = filepath.Join(homeDir, ".config") 20 | } 21 | 22 | return filepath.Join(configDir, "Claude", "claude_desktop_config.json") 23 | } 24 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/server.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import "fmt" 4 | 5 | // Server represents an MCP server configuration entry for Claude Desktop. 6 | type Server struct { 7 | Command string `json:"command"` 8 | Args []string `json:"args,omitempty"` 9 | Env map[string]string `json:"env,omitempty"` 10 | } 11 | 12 | // Print displays the server configuration details. 13 | func (s Server) Print() { 14 | fmt.Printf(" Command: %s\n", s.Command) 15 | if len(s.Args) > 0 { 16 | fmt.Printf(" Args: %v\n", s.Args) 17 | } 18 | if len(s.Env) > 0 { 19 | fmt.Printf(" Environment:\n") 20 | for key, value := range s.Env { 21 | fmt.Printf(" %s: %s\n", key, value) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "log/slog" 5 | "testing" 6 | ) 7 | 8 | func TestParseLogLevel(t *testing.T) { 9 | tests := []struct { 10 | input string 11 | expected slog.Level 12 | }{ 13 | {"debug", slog.LevelDebug}, 14 | {"DEBUG", slog.LevelDebug}, 15 | {"info", slog.LevelInfo}, 16 | {"INFO", slog.LevelInfo}, 17 | {"warn", slog.LevelWarn}, 18 | {"WARN", slog.LevelWarn}, 19 | {"error", slog.LevelError}, 20 | {"ERROR", slog.LevelError}, 21 | {"unknown", slog.LevelInfo}, // Default 22 | {"", slog.LevelInfo}, // Default 23 | } 24 | 25 | for _, tt := range tests { 26 | t.Run(tt.input, func(t *testing.T) { 27 | result := parseLogLevel(tt.input) 28 | if result != tt.expected { 29 | t.Errorf("Expected %v, got %v", tt.expected, result) 30 | } 31 | }) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Code coverage profiles and other test artifacts 15 | *.out 16 | coverage.* 17 | *.coverprofile 18 | profile.cov 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | 23 | # Go workspace file 24 | go.work 25 | go.work.sum 26 | 27 | # env file 28 | .env 29 | 30 | # Editor/IDE 31 | # .idea/ 32 | .vscode/ 33 | .cursor/ 34 | 35 | # build output 36 | bin/ 37 | output/ 38 | *.pem 39 | 40 | # log files 41 | *.log 42 | *mcp-tools.json 43 | *.backup 44 | .DS_Store -------------------------------------------------------------------------------- /root.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "github.com/njayp/ophis/internal/cfgmgr/cmd/claude" 5 | "github.com/njayp/ophis/internal/cfgmgr/cmd/cursor" 6 | "github.com/njayp/ophis/internal/cfgmgr/cmd/vscode" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // Command creates MCP server management commands for a Cobra CLI. 11 | // Pass nil for default configuration or provide a Config for customization. 12 | func Command(config *Config) *cobra.Command { 13 | cmd := &cobra.Command{ 14 | Use: "mcp", 15 | Short: "MCP server management", 16 | Long: `Manage MCP servers for AI assistants and code editors`, 17 | } 18 | 19 | // Add subcommands 20 | cmd.AddCommand( 21 | startCommand(config), 22 | toolCommand(config), 23 | streamCommand(config), 24 | claude.Command(), 25 | vscode.Command(), 26 | cursor.Command(), 27 | ) 28 | return cmd 29 | } 30 | -------------------------------------------------------------------------------- /examples/helm/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/njayp/ophis" 4 | 5 | func config() *ophis.Config { 6 | return &ophis.Config{ 7 | Selectors: selectors(), 8 | } 9 | } 10 | 11 | func selectors() []ophis.Selector { 12 | return []ophis.Selector{ 13 | { 14 | CmdSelector: ophis.AllowCmds( 15 | "helm get hooks", 16 | "helm get manifest", 17 | "helm get notes", 18 | "helm get values", 19 | "helm history", 20 | "helm list", 21 | "helm search hub", 22 | "helm search repo", 23 | "helm show chart", 24 | "helm show crds", 25 | "helm show readme", 26 | "helm show values", 27 | "helm status", 28 | "helm repo list", 29 | "helm template", 30 | "helm dependency list", 31 | "helm lint", 32 | ), 33 | 34 | InheritedFlagSelector: ophis.AllowFlags("namespace"), 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /schema.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import "github.com/njayp/ophis/internal/schema" 4 | 5 | // ToolInput represents the input structure for command tools. 6 | // Do not `omitempty` the Flags field, there may be required flags inside. 7 | type ToolInput struct { 8 | Flags map[string]any `json:"flags" jsonschema:"Command line flags"` 9 | Args []string `json:"args,omitempty" jsonschema:"Positional command line arguments"` 10 | } 11 | 12 | // ToolOutput represents the output structure for command tools. 13 | type ToolOutput struct { 14 | StdOut string `json:"stdout,omitempty" jsonschema:"Standard output"` 15 | StdErr string `json:"stderr,omitempty" jsonschema:"Standard error"` 16 | ExitCode int `json:"exitCode" jsonschema:"Exit code"` 17 | } 18 | 19 | var ( 20 | inputSchema = schema.New[ToolInput]() 21 | outputSchema = schema.New[ToolOutput]() 22 | ) 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/njayp/ophis 2 | 3 | go 1.24.6 4 | 5 | require ( 6 | github.com/google/jsonschema-go v0.3.0 7 | github.com/modelcontextprotocol/go-sdk v1.1.0 8 | github.com/spf13/cobra v1.10.2 9 | github.com/spf13/pflag v1.0.10 10 | github.com/stretchr/testify v1.11.1 11 | ) 12 | 13 | require ( 14 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 15 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 16 | github.com/kr/pretty v0.3.1 // indirect 17 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 18 | github.com/rogpeppe/go-internal v1.14.1 // indirect 19 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 20 | golang.org/x/oauth2 v0.33.0 // indirect 21 | golang.org/x/tools v0.37.0 // indirect 22 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect 23 | gopkg.in/yaml.v3 v3.0.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/cursor.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // ConfigPath returns the platform-specific default path for Cursor configuration. 9 | // If workspace is true, returns workspace configuration path (.cursor/mcp.json), 10 | // otherwise returns user-level configuration path. 11 | func ConfigPath(workspace bool) string { 12 | if workspace { 13 | return getDefaultWorkspaceConfigPath() 14 | } 15 | 16 | return getDefaultCursorUserConfigPath() 17 | } 18 | 19 | // getDefaultWorkspaceConfigPath returns the default workspace configuration path (.vscode/mcp.json). 20 | func getDefaultWorkspaceConfigPath() string { 21 | workingDir, err := os.Getwd() 22 | if err != nil { 23 | // Fallback to current directory 24 | return filepath.Join(".cursor", "mcp.json") 25 | } 26 | 27 | return filepath.Join(workingDir, ".cursor", "mcp.json") 28 | } 29 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/vscode.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // ConfigPath returns the provided path if non-empty, otherwise returns the 9 | // platform-specific default path for VSCode configuration. 10 | // If workspace is true, returns workspace configuration path (.vscode/mcp.json), 11 | // otherwise returns user-level configuration path. 12 | func ConfigPath(workspace bool) string { 13 | if workspace { 14 | return getDefaultWorkspaceConfigPath() 15 | } 16 | 17 | return getDefaultVSCodeUserConfigPath() 18 | } 19 | 20 | // getDefaultWorkspaceConfigPath returns the default workspace configuration path (.vscode/mcp.json). 21 | func getDefaultWorkspaceConfigPath() string { 22 | workingDir, err := os.Getwd() 23 | if err != nil { 24 | // Fallback to current directory 25 | return filepath.Join(".vscode", "mcp.json") 26 | } 27 | 28 | return filepath.Join(workingDir, ".vscode", "mcp.json") 29 | } 30 | -------------------------------------------------------------------------------- /internal/schema/cache.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/google/jsonschema-go/jsonschema" 8 | ) 9 | 10 | // New creates a new schema cache for the given type. 11 | func New[T any]() *Cache { 12 | schema, err := jsonschema.For[T](nil) 13 | if err != nil { 14 | panic(fmt.Sprintf("Failed to generate schema: %v", err)) 15 | } 16 | 17 | data, err := json.Marshal(schema) 18 | if err != nil { 19 | panic(fmt.Sprintf("Failed to marshal schema: %v", err)) 20 | } 21 | 22 | return &Cache{ 23 | data: data, 24 | } 25 | } 26 | 27 | // Cache stores a cached JSON schema. 28 | type Cache struct { 29 | data []byte 30 | } 31 | 32 | // Copy returns a copy of the cached schema. 33 | func (s *Cache) Copy() *jsonschema.Schema { 34 | schema := &jsonschema.Schema{} 35 | err := json.Unmarshal(s.data, schema) 36 | if err != nil { 37 | panic(fmt.Sprintf("Failed to unmarshal schema: %v", err)) 38 | } 39 | 40 | return schema 41 | } 42 | -------------------------------------------------------------------------------- /examples/helm/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | // Import to initialize client auth plugins. 8 | "github.com/njayp/ophis" 9 | "github.com/spf13/cobra" 10 | helmcmd "helm.sh/helm/v4/pkg/cmd" 11 | "helm.sh/helm/v4/pkg/kube" 12 | _ "k8s.io/client-go/plugin/pkg/client/auth" 13 | ) 14 | 15 | func rootCmd() *cobra.Command { 16 | cmd, err := helmcmd.NewRootCmd(os.Stdout, os.Args[1:], helmcmd.SetupLogging) 17 | if err != nil { 18 | slog.Warn("command failed", slog.Any("error", err)) 19 | os.Exit(1) 20 | } 21 | 22 | // add mcp server commands 23 | cmd.AddCommand(ophis.Command(config())) 24 | 25 | return cmd 26 | } 27 | 28 | // main taken from https://github.com/helm/helm/blob/main/cmd/helm/helm.go 29 | func main() { 30 | kube.ManagedFieldsManager = "helm" 31 | 32 | cmd := rootCmd() 33 | if err := cmd.Execute(); err != nil { 34 | if cerr, ok := err.(helmcmd.CommandError); ok { 35 | os.Exit(cerr.ExitCode) 36 | } 37 | os.Exit(1) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/claude/list.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/njayp/ophis/internal/cfgmgr/manager" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type listFlags struct { 11 | configPath string 12 | } 13 | 14 | // listCommand creates a Cobra command for listing configured MCP servers in Claude Desktop. 15 | func listCommand() *cobra.Command { 16 | f := &listFlags{} 17 | cmd := &cobra.Command{ 18 | Use: "list", 19 | Short: "Show Claude MCP servers", 20 | Long: "Show all MCP servers configured in Claude Desktop", 21 | RunE: func(_ *cobra.Command, _ []string) error { 22 | return f.run() 23 | }, 24 | } 25 | 26 | // Add flags 27 | flags := cmd.Flags() 28 | flags.StringVar(&f.configPath, "config-path", "", "Path to Claude config file") 29 | return cmd 30 | } 31 | 32 | func (f *listFlags) run() error { 33 | m, err := manager.NewClaudeManager(f.configPath) 34 | if err != nil { 35 | return err 36 | } 37 | 38 | fmt.Printf("Claude Desktop MCP servers:\n\n") 39 | m.Print() 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | all: up lint test 3 | go run github.com/magefile/mage@latest all 4 | 5 | .PHONY: up 6 | up: 7 | go get -u ./... 8 | go mod tidy 9 | 10 | .PHONY: lint 11 | lint: 12 | golangci-lint fmt ./... 13 | golangci-lint run ./... 14 | 15 | .PHONY: test 16 | test: 17 | go test ./... 18 | 19 | .PHONY: build 20 | build: 21 | go run github.com/magefile/mage@latest build 22 | 23 | .PHONY: release 24 | release: all 25 | @echo "Creating release..." 26 | @if ! git diff-index --quiet HEAD --; then \ 27 | echo "Error: Working directory is not clean. Please commit or stash changes."; \ 28 | exit 1; \ 29 | fi 30 | @LATEST_TAG=$$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0"); \ 31 | echo "Latest tag: $$LATEST_TAG"; \ 32 | VERSION=$$(echo $$LATEST_TAG | sed 's/^v//' | awk -F. '{print $$1"."$$2"."$$3+1}'); \ 33 | NEW_TAG="v$$VERSION"; \ 34 | echo "Creating new tag: $$NEW_TAG"; \ 35 | git tag -a $$NEW_TAG -m "Release $$NEW_TAG"; \ 36 | git push origin $$NEW_TAG; \ 37 | echo "Successfully created and pushed tag: $$NEW_TAG" 38 | 39 | -------------------------------------------------------------------------------- /.github/workflows/issue-open.yaml: -------------------------------------------------------------------------------- 1 | name: issue open 2 | 3 | on: 4 | issues: 5 | types: [opened, reopened] 6 | 7 | jobs: 8 | assign-owner: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | steps: 13 | - name: Assign issue to owner 14 | uses: actions/github-script@v7 15 | with: 16 | script: | 17 | // Only assign if the issue author is not the repository owner 18 | if (context.payload.issue.user.login !== context.repo.owner) { 19 | // Assign issue to owner 20 | await github.rest.issues.addAssignees({ 21 | owner: context.repo.owner, 22 | repo: context.repo.repo, 23 | issue_number: context.issue.number, 24 | assignees: [context.repo.owner] 25 | }); 26 | 27 | console.log(`Successfully assigned issue #${context.issue.number} to ${context.repo.owner}`); 28 | } else { 29 | console.log('Skipping assignment: Issue author is the repository owner'); 30 | } 31 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/config.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import "fmt" 4 | 5 | // Config represents the structure of Claude Desktop's configuration file. 6 | type Config struct { 7 | Servers map[string]Server `json:"mcpServers"` 8 | } 9 | 10 | // AddServer adds or updates a server in the configuration. 11 | func (c *Config) AddServer(name string, server Server) { 12 | if c.Servers == nil { 13 | c.Servers = make(map[string]Server) 14 | } 15 | c.Servers[name] = server 16 | } 17 | 18 | // HasServer returns true if a server with the given name exists in the configuration. 19 | func (c *Config) HasServer(name string) bool { 20 | _, ok := c.Servers[name] 21 | return ok 22 | } 23 | 24 | // RemoveServer removes a server from the configuration. 25 | func (c *Config) RemoveServer(name string) { 26 | delete(c.Servers, name) 27 | } 28 | 29 | // Print displays all configured MCP servers. 30 | func (c *Config) Print() { 31 | if len(c.Servers) == 0 { 32 | fmt.Println("No MCP servers are currently configured.") 33 | return 34 | } 35 | 36 | for name, server := range c.Servers { 37 | fmt.Printf("Server: %s\n", name) 38 | server.Print() 39 | fmt.Println() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/cursor/list.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/njayp/ophis/internal/cfgmgr/manager" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type listFlags struct { 11 | configPath string 12 | workspace bool 13 | } 14 | 15 | // listCommand creates a Cobra command for listing configured MCP servers in Cursor. 16 | func listCommand() *cobra.Command { 17 | f := &listFlags{} 18 | cmd := &cobra.Command{ 19 | Use: "list", 20 | Short: "Show Cursor MCP servers", 21 | Long: "Show all MCP servers configured in Cursor", 22 | RunE: func(_ *cobra.Command, _ []string) error { 23 | return f.run() 24 | }, 25 | } 26 | 27 | // Add flags 28 | flags := cmd.Flags() 29 | flags.StringVar(&f.configPath, "config-path", "", "Path to Cursor config file") 30 | flags.BoolVar(&f.workspace, "workspace", false, "List from workspace settings (.cursor/mcp.json) instead of user settings") 31 | 32 | return cmd 33 | } 34 | 35 | func (f *listFlags) run() error { 36 | m, err := manager.NewCursorManager(f.configPath, f.workspace) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | fmt.Printf("Cursor MCP servers:\n\n") 42 | m.Print() 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/vscode/list.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/njayp/ophis/internal/cfgmgr/manager" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | type listFlags struct { 11 | configPath string 12 | workspace bool 13 | } 14 | 15 | // listCommand creates a Cobra command for listing configured MCP servers in VSCode. 16 | func listCommand() *cobra.Command { 17 | f := &listFlags{} 18 | cmd := &cobra.Command{ 19 | Use: "list", 20 | Short: "Show VSCode MCP servers", 21 | Long: "Show all MCP servers configured in VSCode", 22 | RunE: func(_ *cobra.Command, _ []string) error { 23 | return f.run() 24 | }, 25 | } 26 | 27 | // Add flags 28 | flags := cmd.Flags() 29 | flags.StringVar(&f.configPath, "config-path", "", "Path to VSCode config file") 30 | flags.BoolVar(&f.workspace, "workspace", false, "List from workspace settings (.vscode/mcp.json) instead of user settings") 31 | 32 | return cmd 33 | } 34 | 35 | func (f *listFlags) run() error { 36 | m, err := manager.NewVSCodeManager(f.configPath, f.workspace) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | fmt.Printf("VSCode MCP servers:\n\n") 42 | m.Print() 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/server.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import "fmt" 4 | 5 | // Server represents an MCP server configuration entry for Cursor. 6 | type Server struct { 7 | Type string `json:"type,omitempty"` 8 | Command string `json:"command,omitempty"` 9 | Args []string `json:"args,omitempty"` 10 | Env map[string]string `json:"env,omitempty"` 11 | URL string `json:"url,omitempty"` 12 | Headers map[string]string `json:"headers,omitempty"` 13 | } 14 | 15 | // Print displays the server configuration details. 16 | func (s Server) Print() { 17 | fmt.Printf(" Type: %s\n", s.Type) 18 | if s.Command != "" { 19 | fmt.Printf(" Command: %s\n", s.Command) 20 | } 21 | if s.URL != "" { 22 | fmt.Printf(" URL: %s\n", s.URL) 23 | } 24 | if len(s.Args) > 0 { 25 | fmt.Printf(" Args: %v\n", s.Args) 26 | } 27 | if len(s.Env) > 0 { 28 | fmt.Printf(" Environment:\n") 29 | for key, value := range s.Env { 30 | fmt.Printf(" %s: %s\n", key, value) 31 | } 32 | } 33 | if len(s.Headers) > 0 { 34 | fmt.Printf(" Headers:\n") 35 | for key, value := range s.Headers { 36 | fmt.Printf(" %s: %s\n", key, value) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/server.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import "fmt" 4 | 5 | // Server represents an MCP server configuration entry for VSCode. 6 | type Server struct { 7 | Type string `json:"type,omitempty"` 8 | Command string `json:"command,omitempty"` 9 | Args []string `json:"args,omitempty"` 10 | Env map[string]string `json:"env,omitempty"` 11 | URL string `json:"url,omitempty"` 12 | Headers map[string]string `json:"headers,omitempty"` 13 | } 14 | 15 | // Print displays the server configuration details. 16 | func (s Server) Print() { 17 | fmt.Printf(" Type: %s\n", s.Type) 18 | if s.Command != "" { 19 | fmt.Printf(" Command: %s\n", s.Command) 20 | } 21 | if s.URL != "" { 22 | fmt.Printf(" URL: %s\n", s.URL) 23 | } 24 | if len(s.Args) > 0 { 25 | fmt.Printf(" Args: %v\n", s.Args) 26 | } 27 | if len(s.Env) > 0 { 28 | fmt.Printf(" Environment:\n") 29 | for key, value := range s.Env { 30 | fmt.Printf(" %s: %s\n", key, value) 31 | } 32 | } 33 | if len(s.Headers) > 0 { 34 | fmt.Printf(" Headers:\n") 35 | for key, value := range s.Headers { 36 | fmt.Printf(" %s: %s\n", key, value) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /examples/kubectl/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/njayp/ophis" 7 | "github.com/spf13/cobra" 8 | _ "k8s.io/client-go/plugin/pkg/client/auth" 9 | "k8s.io/component-base/cli" 10 | "k8s.io/component-base/logs" 11 | "k8s.io/kubectl/pkg/cmd" 12 | "k8s.io/kubectl/pkg/cmd/util" 13 | ) 14 | 15 | func rootCmd() *cobra.Command { 16 | command := cmd.NewDefaultKubectlCommand() 17 | 18 | // Add MCP server commands 19 | command.AddCommand(ophis.Command(&ophis.Config{ 20 | Selectors: []ophis.Selector{ 21 | { 22 | CmdSelector: ophis.AllowCmds( 23 | "kubectl get", 24 | "kubectl describe", 25 | "kubectl logs", 26 | "kubectl explain", 27 | "kubectl api-resources", 28 | ), 29 | 30 | InheritedFlagSelector: ophis.AllowFlags( 31 | "namespace", 32 | "output", 33 | ), 34 | }, 35 | }, 36 | })) 37 | 38 | return command 39 | } 40 | 41 | // main taken from https://github.com/kubernetes/kubernetes/blob/master/cmd/kubectl/kubectl.go 42 | func main() { 43 | logs.GlogSetter(cmd.GetLogVerbosity(os.Args)) // nolint:errcheck 44 | 45 | command := rootCmd() 46 | if err := cli.RunNoErrOutput(command); err != nil { 47 | util.CheckErr(err) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /start.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "log/slog" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | // startCommandFlags holds flags for the start command. 10 | type startCommandFlags struct { 11 | logLevel string 12 | } 13 | 14 | // startCommand creates the 'mcp start' command. 15 | func startCommand(config *Config) *cobra.Command { 16 | f := &startCommandFlags{} 17 | cmd := &cobra.Command{ 18 | Use: "start", 19 | Short: "Start the MCP server", 20 | Long: `Start stdio server to expose CLI commands to AI assistants`, 21 | RunE: func(cmd *cobra.Command, _ []string) error { 22 | if config == nil { 23 | config = &Config{} 24 | } 25 | 26 | if f.logLevel != "" { 27 | level := parseLogLevel(f.logLevel) 28 | // Ensure SloggerOptions is initialized 29 | if config.SloggerOptions == nil { 30 | config.SloggerOptions = &slog.HandlerOptions{} 31 | } 32 | // Set the log level based on the flag 33 | config.SloggerOptions.Level = level 34 | } 35 | 36 | // Create and start the server 37 | return config.serveStdio(cmd) 38 | }, 39 | } 40 | 41 | // Add flags 42 | flags := cmd.Flags() 43 | flags.StringVar(&f.logLevel, "log-level", "", "Log level (debug, info, warn, error)") 44 | return cmd 45 | } 46 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/utils.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "slices" 7 | "strings" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // DeriveServerName extracts the server name from an executable path. 13 | func DeriveServerName(executablePath string) string { 14 | serverName := filepath.Base(executablePath) 15 | // Remove extension if present 16 | if ext := filepath.Ext(serverName); ext != "" { 17 | serverName = serverName[:len(serverName)-len(ext)] 18 | } 19 | return serverName 20 | } 21 | 22 | // GetCmdPath builds the command path to the MCP command. 23 | // It returns the slice of command names from after the root command up to and including "mcp". 24 | // 25 | // Example: for command path "myapp alpha mcp start", returns ["alpha", "mcp"]. 26 | // 27 | // The returned slice can be used as arguments when invoking the executable. 28 | // Returns an error if "mcp" is not found in the command path. 29 | func GetCmdPath(cmd *cobra.Command) ([]string, error) { 30 | path := cmd.CommandPath() 31 | args := strings.Fields(path) 32 | 33 | // Find the index of "mcp" in the command path 34 | name := "mcp" 35 | index := slices.Index(args, name) 36 | if index == -1 { 37 | return nil, fmt.Errorf("command %q not found in path %q", name, path) 38 | } 39 | 40 | // Return the slice from after the root command to the MCP command 41 | return args[1 : index+1], nil 42 | } 43 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/spf13/cobra" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCmdFilter(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | expected bool 14 | cmd *cobra.Command 15 | }{ 16 | { 17 | name: "passing cmd", 18 | expected: false, 19 | cmd: &cobra.Command{ 20 | Use: "test", 21 | Run: func(_ *cobra.Command, _ []string) {}, 22 | }, 23 | }, 24 | { 25 | name: "depreciated cmd", 26 | expected: true, 27 | cmd: &cobra.Command{ 28 | Use: "test", 29 | Run: func(_ *cobra.Command, _ []string) {}, 30 | Deprecated: "test", 31 | }, 32 | }, 33 | { 34 | name: "hidden cmd", 35 | expected: true, 36 | cmd: &cobra.Command{ 37 | Use: "test", 38 | Run: func(_ *cobra.Command, _ []string) {}, 39 | Hidden: true, 40 | }, 41 | }, 42 | { 43 | name: "mcp cmd", 44 | expected: true, 45 | cmd: &cobra.Command{ 46 | Use: "mcp", 47 | Run: func(_ *cobra.Command, _ []string) {}, 48 | }, 49 | }, 50 | { 51 | name: "no run cmd", 52 | expected: true, 53 | cmd: &cobra.Command{ 54 | Use: "test", 55 | }, 56 | }, 57 | } 58 | 59 | for _, tt := range tests { 60 | t.Run(tt.name, func(t *testing.T) { 61 | result := cmdFilter(tt.cmd) 62 | assert.Equal(t, tt.expected, result) 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /docs/execution.md: -------------------------------------------------------------------------------- 1 | # Tool Execution 2 | 3 | When an AI assistant calls an MCP tool, Ophis executes your CLI as a subprocess. 4 | 5 | ## Execution Flow 6 | 7 | 1. **Middleware** (optional) - Wraps execution with custom logic 8 | 2. **Command Execution** - Spawns CLI subprocess, captures output 9 | 10 | ## Command Construction 11 | 12 | MCP tool calls become CLI invocations: 13 | 14 | **Input:** 15 | ```json 16 | { 17 | "name": "kubectl_get_pods", 18 | "arguments": { 19 | "flags": { 20 | "namespace": "production", 21 | "output": "json" 22 | }, 23 | "args": ["web-server"] 24 | } 25 | } 26 | ``` 27 | 28 | **Constructed:** 29 | ```bash 30 | /path/to/kubectl get pods --namespace production --output json web-server 31 | ``` 32 | 33 | **Flag conversion:** 34 | - Boolean: `true` → `--flag`, `false` → omitted 35 | - String/numeric: `--flag value` 36 | - Arrays: `--flag a --flag b` 37 | - Null/empty: omitted 38 | 39 | ## Output 40 | 41 | All executions return: 42 | 43 | ```json 44 | { 45 | "stdout": "command output...", 46 | "stderr": "error messages...", 47 | "exitCode": 0 48 | } 49 | ``` 50 | 51 | Non-zero exit codes indicate command errors (not execution failures). 52 | 53 | ## Cancellation 54 | 55 | Execution can be cancelled by: 56 | - Middleware returning early without calling next 57 | - MCP client cancelling request 58 | - Parent context timeout 59 | 60 | Cancelled executions kill the subprocess and return an error. 61 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/claude/disable.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/njayp/ophis/internal/cfgmgr/manager" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type disableFlags struct { 12 | configPath string 13 | serverName string 14 | } 15 | 16 | // disableCommand creates a Cobra command for removing an MCP server from Claude Desktop. 17 | func disableCommand() *cobra.Command { 18 | f := &disableFlags{} 19 | cmd := &cobra.Command{ 20 | Use: "disable", 21 | Short: "Remove server from Claude config", 22 | Long: "Remove this application from Claude Desktop MCP servers", 23 | RunE: func(_ *cobra.Command, _ []string) error { 24 | return f.run() 25 | }, 26 | } 27 | 28 | // Add flags 29 | flags := cmd.Flags() 30 | flags.StringVar(&f.configPath, "config-path", "", "Path to Claude config file") 31 | flags.StringVar(&f.serverName, "server-name", "", "Name of the MCP server to remove (default: derived from executable name)") 32 | return cmd 33 | } 34 | 35 | func (f *disableFlags) run() error { 36 | if f.serverName == "" { 37 | executablePath, err := os.Executable() 38 | if err != nil { 39 | return fmt.Errorf("failed to determine executable path: %w", err) 40 | } 41 | 42 | f.serverName = manager.DeriveServerName(executablePath) 43 | } 44 | 45 | m, err := manager.NewClaudeManager(f.configPath) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | return m.DisableServer(f.serverName) 51 | } 52 | -------------------------------------------------------------------------------- /examples/make/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | 7 | "github.com/njayp/ophis" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func main() { 12 | if err := makeCmd().Execute(); err != nil { 13 | os.Exit(1) 14 | } 15 | } 16 | 17 | type flags struct { 18 | File string 19 | Directory string 20 | } 21 | 22 | func makeCmd() *cobra.Command { 23 | flags := &flags{} 24 | // Create the root make command 25 | cmd := &cobra.Command{ 26 | Use: "make [targets...]", 27 | Short: "Execute make targets", 28 | RunE: flags.run, 29 | Args: cobra.ArbitraryArgs, 30 | } 31 | 32 | cmd.Flags().StringVarP(&flags.File, "file", "f", "", "Use FILE as a makefile") 33 | cmd.Flags().StringVarP(&flags.Directory, "directory", "C", "", "Change to directory before doing anything") 34 | err := cmd.MarkFlagRequired("directory") 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | // Add subcommands 40 | cmd.AddCommand(ophis.Command(nil)) 41 | return cmd 42 | } 43 | 44 | func (f *flags) run(cmd *cobra.Command, args []string) error { 45 | // Handle --file/-f flag 46 | if f.File != "" { 47 | args = append(args, "-f", f.File) 48 | } 49 | 50 | // Handle --directory/-C flag 51 | if f.Directory != "" { 52 | args = append(args, "-C", f.Directory) 53 | } 54 | 55 | subCmd := exec.CommandContext(cmd.Context(), "make", args...) 56 | subCmd.Stdout = cmd.OutOrStdout() 57 | subCmd.Stderr = cmd.ErrOrStderr() 58 | return subCmd.Run() 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/smoke-test.yaml: -------------------------------------------------------------------------------- 1 | name: smoke test 2 | 3 | # This is a reusable workflow that validates the project 4 | on: 5 | workflow_call: 6 | inputs: 7 | go-version: 8 | description: "Go version to use" 9 | required: false 10 | type: string 11 | default: "1.24" 12 | fetch-depth: 13 | description: "Number of commits to fetch (0 for all history)" 14 | required: false 15 | type: number 16 | default: 1 17 | 18 | jobs: 19 | validate: 20 | runs-on: ubuntu-latest 21 | steps: 22 | # Check out the repository code with specified fetch depth 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | with: 26 | fetch-depth: ${{ inputs.fetch-depth }} 27 | 28 | # Set up Go 29 | - name: Set up Go 30 | uses: actions/setup-go@v5 31 | with: 32 | go-version: ${{ inputs.go-version }} 33 | 34 | # Install Mage 35 | - name: Install Mage 36 | run: go install github.com/magefile/mage@latest 37 | 38 | # Install golangci-lint 39 | - name: Install golangci-lint 40 | run: | 41 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin latest 42 | 43 | # Run smoke test using make 44 | - name: Smoke test 45 | run: | 46 | make up lint test 47 | git diff --exit-code go.mod go.sum 48 | -------------------------------------------------------------------------------- /stream.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | // streamCommand holds flags for the stream command. 11 | type streamCommandFlags struct { 12 | logLevel string 13 | host string 14 | port int 15 | } 16 | 17 | // startCommand creates the 'mcp start' command. 18 | func streamCommand(config *Config) *cobra.Command { 19 | f := &streamCommandFlags{} 20 | cmd := &cobra.Command{ 21 | Use: "stream", 22 | Short: "Stream the MCP server over HTTP", 23 | Long: `Start HTTP server to expose CLI commands to AI assistants`, 24 | RunE: func(cmd *cobra.Command, _ []string) error { 25 | if config == nil { 26 | config = &Config{} 27 | } 28 | 29 | if f.logLevel != "" { 30 | level := parseLogLevel(f.logLevel) 31 | // Ensure SloggerOptions is initialized 32 | if config.SloggerOptions == nil { 33 | config.SloggerOptions = &slog.HandlerOptions{} 34 | } 35 | // Set the log level based on the flag 36 | config.SloggerOptions.Level = level 37 | } 38 | 39 | // Create and start the server 40 | return config.serveHTTP(cmd, fmt.Sprintf("%s:%d", f.host, f.port)) 41 | }, 42 | } 43 | 44 | // Add flags 45 | flags := cmd.Flags() 46 | flags.StringVar(&f.logLevel, "log-level", "", "Log level (debug, info, warn, error)") 47 | flags.StringVar(&f.host, "host", "", "host to listen on") 48 | flags.IntVar(&f.port, "port", 8080, "port number to listen on") 49 | return cmd 50 | } 51 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/config.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import "fmt" 4 | 5 | // Config represents the structure of VSCode's MCP configuration file. 6 | type Config struct { 7 | Inputs []Input `json:"inputs,omitempty"` 8 | Servers map[string]Server `json:"servers"` 9 | } 10 | 11 | // Input represents a VSCode input variable configuration. 12 | type Input struct { 13 | Type string `json:"type"` 14 | ID string `json:"id"` 15 | Description string `json:"description"` 16 | Password bool `json:"password,omitempty"` 17 | } 18 | 19 | // AddServer adds or updates a server in the configuration. 20 | func (c *Config) AddServer(name string, server Server) { 21 | if c.Servers == nil { 22 | c.Servers = make(map[string]Server) 23 | } 24 | c.Servers[name] = server 25 | } 26 | 27 | // HasServer returns true if a server with the given name exists in the configuration. 28 | func (c *Config) HasServer(name string) bool { 29 | _, ok := c.Servers[name] 30 | return ok 31 | } 32 | 33 | // RemoveServer removes a server from the configuration. 34 | func (c *Config) RemoveServer(name string) { 35 | delete(c.Servers, name) 36 | } 37 | 38 | // Print displays all configured MCP servers. 39 | func (c *Config) Print() { 40 | if len(c.Servers) == 0 { 41 | fmt.Println("No MCP servers are currently configured.") 42 | return 43 | } 44 | 45 | for name, server := range c.Servers { 46 | fmt.Printf("Server: %s\n", name) 47 | server.Print() 48 | fmt.Println() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/config.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import "fmt" 4 | 5 | // Config represents the structure of Cursor's MCP configuration file. 6 | type Config struct { 7 | Inputs []Input `json:"inputs,omitempty"` 8 | Servers map[string]Server `json:"mcpServers"` 9 | } 10 | 11 | // Input represents a Cursor input variable configuration. 12 | type Input struct { 13 | Type string `json:"type"` 14 | ID string `json:"id"` 15 | Description string `json:"description"` 16 | Password bool `json:"password,omitempty"` 17 | } 18 | 19 | // AddServer adds or updates a server in the configuration. 20 | func (c *Config) AddServer(name string, server Server) { 21 | if c.Servers == nil { 22 | c.Servers = make(map[string]Server) 23 | } 24 | c.Servers[name] = server 25 | } 26 | 27 | // HasServer returns true if a server with the given name exists in the configuration. 28 | func (c *Config) HasServer(name string) bool { 29 | _, ok := c.Servers[name] 30 | return ok 31 | } 32 | 33 | // RemoveServer removes a server from the configuration. 34 | func (c *Config) RemoveServer(name string) { 35 | delete(c.Servers, name) 36 | } 37 | 38 | // Print displays all configured MCP servers. 39 | func (c *Config) Print() { 40 | if len(c.Servers) == 0 { 41 | fmt.Println("No MCP servers are currently configured.") 42 | return 43 | } 44 | 45 | for name, server := range c.Servers { 46 | fmt.Printf("Server: %s\n", name) 47 | server.Print() 48 | fmt.Println() 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-open.yaml: -------------------------------------------------------------------------------- 1 | name: pull request open 2 | 3 | on: 4 | pull_request_target: 5 | branches: [ main ] 6 | types: [opened, reopened] 7 | 8 | jobs: 9 | assign-and-review: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | issues: write 13 | pull-requests: write 14 | steps: 15 | - name: Assign PR to owner and request review 16 | uses: actions/github-script@v7 17 | with: 18 | script: | 19 | // Only assign if the PR author is not the repository owner 20 | if (context.payload.pull_request.user.login !== context.repo.owner) { 21 | // Assign PR to owner 22 | await github.rest.issues.addAssignees({ 23 | owner: context.repo.owner, 24 | repo: context.repo.repo, 25 | issue_number: context.issue.number, 26 | assignees: [context.repo.owner] 27 | }); 28 | 29 | // Request review from owner 30 | await github.rest.pulls.requestReviewers({ 31 | owner: context.repo.owner, 32 | repo: context.repo.repo, 33 | pull_number: context.issue.number, 34 | reviewers: [context.repo.owner] 35 | }); 36 | 37 | console.log(`Successfully assigned PR #${context.issue.number} to ${context.repo.owner} and requested review`); 38 | } else { 39 | console.log('Skipping assignment: PR author is the repository owner'); 40 | } -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/cursor/disable.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/njayp/ophis/internal/cfgmgr/manager" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type disableFlags struct { 12 | configPath string 13 | serverName string 14 | workspace bool 15 | } 16 | 17 | // disableCommand creates a Cobra command for removing an MCP server from Cursor. 18 | func disableCommand() *cobra.Command { 19 | f := &disableFlags{} 20 | cmd := &cobra.Command{ 21 | Use: "disable", 22 | Short: "Remove server from Cursor config", 23 | Long: "Remove this application from Cursor MCP servers", 24 | RunE: func(_ *cobra.Command, _ []string) error { 25 | return f.run() 26 | }, 27 | } 28 | 29 | // Add flags 30 | flags := cmd.Flags() 31 | flags.StringVar(&f.configPath, "config-path", "", "Path to Cursor config file") 32 | flags.StringVar(&f.serverName, "server-name", "", "Name of the MCP server to remove (default: derived from executable name)") 33 | flags.BoolVar(&f.workspace, "workspace", false, "Remove from workspace settings (.cursor/mcp.json) instead of user settings") 34 | 35 | return cmd 36 | } 37 | 38 | func (f *disableFlags) run() error { 39 | if f.serverName == "" { 40 | executablePath, err := os.Executable() 41 | if err != nil { 42 | return fmt.Errorf("failed to determine executable path: %w", err) 43 | } 44 | 45 | f.serverName = manager.DeriveServerName(executablePath) 46 | } 47 | 48 | m, err := manager.NewCursorManager(f.configPath, f.workspace) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return m.DisableServer(f.serverName) 54 | } 55 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/vscode/disable.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/njayp/ophis/internal/cfgmgr/manager" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | type disableFlags struct { 12 | configPath string 13 | serverName string 14 | workspace bool 15 | } 16 | 17 | // disableCommand creates a Cobra command for removing an MCP server from VSCode. 18 | func disableCommand() *cobra.Command { 19 | f := &disableFlags{} 20 | cmd := &cobra.Command{ 21 | Use: "disable", 22 | Short: "Remove server from VSCode config", 23 | Long: "Remove this application from VSCode MCP servers", 24 | RunE: func(_ *cobra.Command, _ []string) error { 25 | return f.run() 26 | }, 27 | } 28 | 29 | // Add flags 30 | flags := cmd.Flags() 31 | flags.StringVar(&f.configPath, "config-path", "", "Path to VSCode config file") 32 | flags.StringVar(&f.serverName, "server-name", "", "Name of the MCP server to remove (default: derived from executable name)") 33 | flags.BoolVar(&f.workspace, "workspace", false, "Remove from workspace settings (.vscode/mcp.json) instead of user settings") 34 | 35 | return cmd 36 | } 37 | 38 | func (f *disableFlags) run() error { 39 | if f.serverName == "" { 40 | executablePath, err := os.Executable() 41 | if err != nil { 42 | return fmt.Errorf("failed to determine executable path: %w", err) 43 | } 44 | 45 | f.serverName = manager.DeriveServerName(executablePath) 46 | } 47 | 48 | m, err := manager.NewVSCodeManager(f.configPath, f.workspace) 49 | if err != nil { 50 | return err 51 | } 52 | 53 | return m.DisableServer(f.serverName) 54 | } 55 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // toolCommandFlags holds flags for the tools command. 13 | type toolCommandFlags struct { 14 | logLevel string 15 | } 16 | 17 | // toolCommand creates the 'mcp tools' command to export tool definitions. 18 | func toolCommand(config *Config) *cobra.Command { 19 | toolFlags := &toolCommandFlags{} 20 | cmd := &cobra.Command{ 21 | Use: "tools", 22 | Short: "Export tools as JSON", 23 | Long: `Export available MCP tools to mcp-tools.json for inspection`, 24 | RunE: func(cmd *cobra.Command, _ []string) error { 25 | if config == nil { 26 | config = &Config{} 27 | } 28 | 29 | if toolFlags.logLevel != "" { 30 | if config.SloggerOptions == nil { 31 | config.SloggerOptions = &slog.HandlerOptions{} 32 | } 33 | 34 | config.SloggerOptions.Level = parseLogLevel(toolFlags.logLevel) 35 | } 36 | 37 | file, err := os.OpenFile("mcp-tools.json", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 38 | if err != nil { 39 | return fmt.Errorf("failed to create or open mcp-tools.json file: %w", err) 40 | } 41 | defer func() { 42 | if closeErr := file.Close(); closeErr != nil { 43 | cmd.Printf("Warning: failed to close file: %v\n", closeErr) 44 | } 45 | }() 46 | 47 | encoder := json.NewEncoder(file) 48 | encoder.SetIndent("", " ") 49 | config.registerTools(cmd) 50 | err = encoder.Encode(config.tools) 51 | if err != nil { 52 | return fmt.Errorf("failed to encode MCP tools to JSON: %w", err) 53 | } 54 | 55 | cmd.Printf("Successfully exported %d tools to mcp-tools.json\n", len(config.tools)) 56 | return nil 57 | }, 58 | } 59 | 60 | // Add flags 61 | flags := cmd.Flags() 62 | flags.StringVar(&toolFlags.logLevel, "log-level", "", "Log level (debug, info, warn, error)") 63 | return cmd 64 | } 65 | -------------------------------------------------------------------------------- /examples/argocd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | 8 | cli "github.com/argoproj/argo-cd/v3/cmd/argocd/commands" 9 | "github.com/argoproj/argo-cd/v3/util/log" 10 | "github.com/njayp/ophis" 11 | "github.com/spf13/cobra" 12 | "k8s.io/klog/v2" 13 | ) 14 | 15 | func init() { 16 | // Make sure klog uses the configured log level and format. 17 | klog.SetLogger(log.NewLogrusLogger(log.NewWithCurrentConfig())) 18 | } 19 | 20 | func rootCmd() *cobra.Command { 21 | command := cli.NewCommand() 22 | command.AddCommand(ophis.Command(&ophis.Config{ 23 | Selectors: []ophis.Selector{ 24 | { 25 | CmdSelector: ophis.AllowCmds( 26 | "argocd app get", 27 | "argocd app list", 28 | "argocd app diff", 29 | "argocd app manifests", 30 | "argocd app history", 31 | "argocd app resources", 32 | "argocd app logs", 33 | "argocd app sync", 34 | "argocd app wait", 35 | "argocd app rollback", 36 | "argocd cluster list", 37 | "argocd proj list", 38 | "argocd repo list", 39 | ), 40 | 41 | // No inherited flags 42 | InheritedFlagSelector: ophis.NoFlags, 43 | }, 44 | }, 45 | })) 46 | 47 | return command 48 | } 49 | 50 | // main from https://github.com/argoproj/argo-cd/blob/master/cmd/main.go 51 | func main() { 52 | isArgocdCLI := true 53 | command := rootCmd() 54 | command.SilenceErrors = true 55 | command.SilenceUsage = true 56 | 57 | err := command.Execute() 58 | // if the err is non-nil, try to look for various scenarios 59 | // such as if the error is from the execution of a normal argocd command, 60 | // unknown command error or any other. 61 | if err != nil { 62 | pluginHandler := cli.NewDefaultPluginHandler([]string{"argocd"}) 63 | pluginErr := pluginHandler.HandleCommandExecutionError(err, isArgocdCLI, os.Args) 64 | if pluginErr != nil { 65 | var exitErr *exec.ExitError 66 | if errors.As(pluginErr, &exitErr) { 67 | // Return the actual plugin exit code 68 | os.Exit(exitErr.ExitCode()) 69 | } 70 | // Fallback to exit code 1 if the error isn't an exec.ExitError 71 | os.Exit(1) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/tools_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/njayp/ophis" 7 | "github.com/spf13/cobra" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // createTestCommand creates a simple test command tree for testing. 12 | func createTestCommand() *cobra.Command { 13 | root := &cobra.Command{ 14 | Use: "testcli", 15 | Short: "Test CLI", 16 | } 17 | 18 | get := &cobra.Command{ 19 | Use: "get [resource]", 20 | Short: "Get a resource", 21 | Run: func(_ *cobra.Command, _ []string) {}, 22 | } 23 | get.Flags().String("output", "json", "Output format") 24 | get.Flags().Bool("verbose", false, "Verbose output") 25 | 26 | list := &cobra.Command{ 27 | Use: "list", 28 | Short: "List resources", 29 | Run: func(_ *cobra.Command, _ []string) {}, 30 | } 31 | list.Flags().Int("limit", 10, "Limit results") 32 | 33 | root.AddCommand(get, list) 34 | root.AddCommand(ophis.Command(nil)) 35 | 36 | return root 37 | } 38 | 39 | func TestGetTools(t *testing.T) { 40 | cmd := createTestCommand() 41 | 42 | // Test successful tool generation 43 | tools := GetTools(t, cmd) 44 | 45 | // Should have two tools: get and list 46 | assert.Len(t, tools, 2, "Expected 2 tools") 47 | 48 | // Verify ToolNames helper 49 | ToolNames(t, tools, "testcli_get", "testcli_list") 50 | 51 | // Verify each tool has required properties 52 | for _, tool := range tools { 53 | assert.NotEmpty(t, tool.Name, "Tool should have a name") 54 | assert.NotEmpty(t, tool.Description, "Tool should have a description") 55 | assert.NotNil(t, tool.InputSchema, "Tool should have an input schema") 56 | 57 | // Test GetInputSchema 58 | schema := GetInputSchema(t, tool) 59 | assert.NotNil(t, schema, "Should return a schema") 60 | assert.Equal(t, "object", schema.Type, "Schema type should be object") 61 | } 62 | } 63 | 64 | func TestCmdNamesToToolNames(t *testing.T) { 65 | cmdNames := []string{"get", "list all", "create this item"} 66 | expectedToolNames := []string{"get", "list_all", "create_this_item"} 67 | 68 | toolNames := CmdPathsToToolNames(cmdNames) 69 | assert.Equal(t, expectedToolNames, toolNames, "Tool names should match expected format") 70 | } 71 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/claude/enable.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/njayp/ophis/internal/cfgmgr/manager" 8 | "github.com/njayp/ophis/internal/cfgmgr/manager/claude" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type enableFlags struct { 13 | configPath string 14 | logLevel string 15 | serverName string 16 | env map[string]string 17 | } 18 | 19 | // enableCommand creates a Cobra command for adding an MCP server to Claude Desktop. 20 | func enableCommand() *cobra.Command { 21 | f := &enableFlags{} 22 | cmd := &cobra.Command{ 23 | Use: "enable", 24 | Short: "Add server to Claude config", 25 | Long: "Add this application as an MCP server in Claude Desktop", 26 | RunE: func(cmd *cobra.Command, _ []string) error { 27 | return f.run(cmd) 28 | }, 29 | } 30 | 31 | // Add flags 32 | flags := cmd.Flags() 33 | flags.StringVar(&f.logLevel, "log-level", "", "Log level (debug, info, warn, error)") 34 | flags.StringVar(&f.configPath, "config-path", "", "Path to Claude config file") 35 | flags.StringVar(&f.serverName, "server-name", "", "Name for the MCP server (default: derived from executable name)") 36 | flags.StringToStringVarP(&f.env, "env", "e", nil, "Environment variables (e.g., --env KEY1=value1 --env KEY2=value2)") 37 | return cmd 38 | } 39 | 40 | func (f *enableFlags) run(cmd *cobra.Command) error { 41 | // Get the current executable path 42 | executablePath, err := os.Executable() 43 | if err != nil { 44 | return fmt.Errorf("failed to determine executable path: %w", err) 45 | } 46 | 47 | // Build server configuration 48 | mcpPath, err := manager.GetCmdPath(cmd) 49 | if err != nil { 50 | return fmt.Errorf("failed to determine MCP command path: %w", err) 51 | } 52 | 53 | server := claude.Server{ 54 | Command: executablePath, 55 | Args: append(mcpPath, "start"), 56 | } 57 | 58 | // Add log level to args if specified 59 | if f.logLevel != "" { 60 | server.Args = append(server.Args, "--log-level", f.logLevel) 61 | } 62 | 63 | // Add environment variables if specified 64 | if len(f.env) > 0 { 65 | server.Env = f.env 66 | } 67 | 68 | if f.serverName == "" { 69 | f.serverName = manager.DeriveServerName(executablePath) 70 | } 71 | 72 | m, err := manager.NewClaudeManager(f.configPath) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | return m.EnableServer(f.serverName, server) 78 | } 79 | -------------------------------------------------------------------------------- /selectors.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "slices" 5 | "strings" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/spf13/pflag" 9 | ) 10 | 11 | // AllowCmdsContaining creates a selector that only accepts commands whose path contains a listed phrase. 12 | // Example: AllowCmdsContaining("get", "helm list") includes "kubectl get pods" and "helm list". 13 | func AllowCmdsContaining(substrings ...string) CmdSelector { 14 | return func(cmd *cobra.Command) bool { 15 | for _, s := range substrings { 16 | if strings.Contains(cmd.CommandPath(), s) { 17 | return true 18 | } 19 | } 20 | 21 | return false 22 | } 23 | } 24 | 25 | // ExcludeCmdsContaining creates a selector that rejects commands whose path contains any listed phrase. 26 | // Example: ExcludeCmdsContaining("kubectl delete", "admin") excludes "kubectl delete" and "cli admin user". 27 | func ExcludeCmdsContaining(substrings ...string) CmdSelector { 28 | selector := AllowCmdsContaining(substrings...) 29 | return func(cmd *cobra.Command) bool { 30 | return !selector(cmd) 31 | } 32 | } 33 | 34 | // AllowCmds creates a selector that only accepts commands whose path is listed. 35 | // Example: AllowCmds("kubectl get", "helm list") includes only those exact commands. 36 | func AllowCmds(cmds ...string) CmdSelector { 37 | return func(cmd *cobra.Command) bool { 38 | return slices.Contains(cmds, cmd.CommandPath()) 39 | } 40 | } 41 | 42 | // ExcludeCmds creates a selector that rejects commands whose path is listed. 43 | // Example: ExcludeCmds("kubectl delete", "helm uninstall") excludes those exact commands. 44 | func ExcludeCmds(cmds ...string) CmdSelector { 45 | return func(cmd *cobra.Command) bool { 46 | return !slices.Contains(cmds, cmd.CommandPath()) 47 | } 48 | } 49 | 50 | // AllowFlags creates a selector that only accepts flags whose name is listed. 51 | // Example: AllowFlags("namespace", "output") includes only flags named "namespace" and "output". 52 | func AllowFlags(names ...string) FlagSelector { 53 | return func(flag *pflag.Flag) bool { 54 | return slices.Contains(names, flag.Name) 55 | } 56 | } 57 | 58 | // ExcludeFlags creates a selector that rejects flags whose name is listed. 59 | // Example: ExcludeFlags("color", "kubeconfig") excludes flags named "color" and "kubeconfig". 60 | func ExcludeFlags(names ...string) FlagSelector { 61 | return func(flag *pflag.Flag) bool { 62 | return !slices.Contains(names, flag.Name) 63 | } 64 | } 65 | 66 | // NoFlags is a FlagSelector that excludes all flags. 67 | func NoFlags(_ *pflag.Flag) bool { 68 | return false 69 | } 70 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/cursor/enable.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/njayp/ophis/internal/cfgmgr/manager" 8 | "github.com/njayp/ophis/internal/cfgmgr/manager/cursor" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type enableFlags struct { 13 | configPath string 14 | logLevel string 15 | serverName string 16 | workspace bool 17 | env map[string]string 18 | } 19 | 20 | // enableCommand creates a Cobra command for adding an MCP server to Cursor. 21 | func enableCommand() *cobra.Command { 22 | f := &enableFlags{} 23 | cmd := &cobra.Command{ 24 | Use: "enable", 25 | Short: "Add server to Cursor config", 26 | Long: "Add this application as an MCP server in Cursor", 27 | RunE: func(cmd *cobra.Command, _ []string) error { 28 | return f.run(cmd) 29 | }, 30 | } 31 | 32 | // Add flags 33 | flags := cmd.Flags() 34 | flags.StringVar(&f.logLevel, "log-level", "", "Log level (debug, info, warn, error)") 35 | flags.StringVar(&f.configPath, "config-path", "", "Path to Cursor config file") 36 | flags.StringVar(&f.serverName, "server-name", "", "Name for the MCP server (default: derived from executable name)") 37 | flags.BoolVar(&f.workspace, "workspace", false, "Add to workspace settings (.cursor/mcp.json) instead of user settings") 38 | flags.StringToStringVarP(&f.env, "env", "e", nil, "Environment variables (e.g., --env KEY1=value1 --env KEY2=value2)") 39 | 40 | return cmd 41 | } 42 | 43 | func (f *enableFlags) run(cmd *cobra.Command) error { 44 | // Get the current executable path 45 | executablePath, err := os.Executable() 46 | if err != nil { 47 | return fmt.Errorf("failed to determine executable path: %w", err) 48 | } 49 | 50 | // Build server configuration 51 | mcpPath, err := manager.GetCmdPath(cmd) 52 | if err != nil { 53 | return fmt.Errorf("failed to determine MCP command path: %w", err) 54 | } 55 | 56 | server := cursor.Server{ 57 | Type: "stdio", 58 | Command: executablePath, 59 | Args: append(mcpPath, "start"), 60 | } 61 | 62 | // Add log level to args if specified 63 | if f.logLevel != "" { 64 | server.Args = append(server.Args, "--log-level", f.logLevel) 65 | } 66 | 67 | // Add environment variables if specified 68 | if len(f.env) > 0 { 69 | server.Env = f.env 70 | } 71 | 72 | if f.serverName == "" { 73 | f.serverName = manager.DeriveServerName(executablePath) 74 | } 75 | 76 | m, err := manager.NewCursorManager(f.configPath, f.workspace) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | return m.EnableServer(f.serverName, server) 82 | } 83 | -------------------------------------------------------------------------------- /internal/cfgmgr/cmd/vscode/enable.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/njayp/ophis/internal/cfgmgr/manager" 8 | "github.com/njayp/ophis/internal/cfgmgr/manager/vscode" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | type enableFlags struct { 13 | configPath string 14 | logLevel string 15 | serverName string 16 | workspace bool 17 | env map[string]string 18 | } 19 | 20 | // enableCommand creates a Cobra command for adding an MCP server to VSCode. 21 | func enableCommand() *cobra.Command { 22 | f := &enableFlags{} 23 | cmd := &cobra.Command{ 24 | Use: "enable", 25 | Short: "Add server to VSCode config", 26 | Long: "Add this application as an MCP server in VSCode", 27 | RunE: func(cmd *cobra.Command, _ []string) error { 28 | return f.run(cmd) 29 | }, 30 | } 31 | 32 | // Add flags 33 | flags := cmd.Flags() 34 | flags.StringVar(&f.logLevel, "log-level", "", "Log level (debug, info, warn, error)") 35 | flags.StringVar(&f.configPath, "config-path", "", "Path to VSCode config file") 36 | flags.StringVar(&f.serverName, "server-name", "", "Name for the MCP server (default: derived from executable name)") 37 | flags.BoolVar(&f.workspace, "workspace", false, "Add to workspace settings (.vscode/mcp.json) instead of user settings") 38 | flags.StringToStringVarP(&f.env, "env", "e", nil, "Environment variables (e.g., --env KEY1=value1 --env KEY2=value2)") 39 | 40 | return cmd 41 | } 42 | 43 | func (f *enableFlags) run(cmd *cobra.Command) error { 44 | // Get the current executable path 45 | executablePath, err := os.Executable() 46 | if err != nil { 47 | return fmt.Errorf("failed to determine executable path: %w", err) 48 | } 49 | 50 | // Build server configuration 51 | mcpPath, err := manager.GetCmdPath(cmd) 52 | if err != nil { 53 | return fmt.Errorf("failed to determine MCP command path: %w", err) 54 | } 55 | 56 | server := vscode.Server{ 57 | Type: "stdio", 58 | Command: executablePath, 59 | Args: append(mcpPath, "start"), 60 | } 61 | 62 | // Add log level to args if specified 63 | if f.logLevel != "" { 64 | server.Args = append(server.Args, "--log-level", f.logLevel) 65 | } 66 | 67 | // Add environment variables if specified 68 | if len(f.env) > 0 { 69 | server.Env = f.env 70 | } 71 | 72 | if f.serverName == "" { 73 | f.serverName = manager.DeriveServerName(executablePath) 74 | } 75 | 76 | m, err := manager.NewVSCodeManager(f.configPath, f.workspace) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | return m.EnableServer(f.serverName, server) 82 | } 83 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" # Trigger the workflow on any tag starting with 'v' 7 | 8 | jobs: 9 | smoke-test: 10 | uses: ./.github/workflows/smoke-test.yaml 11 | 12 | release: 13 | needs: smoke-test 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | # Check out the repository code with full history 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 # Fetch full history to get all tags and commits 22 | 23 | # Set up Go (needed again in this job since jobs run on different runners) 24 | - name: Set up Go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: '1.24' 28 | 29 | # Generate changelog 30 | - name: Generate changelog 31 | id: changelog 32 | run: | 33 | # Get the previous tag 34 | PREVIOUS_TAG=$(git tag --sort=-version:refname | head -n 2 | tail -n 1) 35 | CURRENT_TAG=${{ github.ref_name }} 36 | 37 | # If this is the first tag, compare against initial commit 38 | if [ -z "$PREVIOUS_TAG" ] || [ "$PREVIOUS_TAG" = "$CURRENT_TAG" ]; then 39 | PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD) 40 | fi 41 | 42 | echo "Generating changelog from $PREVIOUS_TAG to $CURRENT_TAG" 43 | 44 | # Generate commit messages since last tag 45 | CHANGELOG=$(git log --pretty=format:"- %s" "$PREVIOUS_TAG..$CURRENT_TAG" 2>/dev/null || git log --pretty=format:"- %s" "$PREVIOUS_TAG..HEAD") 46 | 47 | # If no commits found, provide a default message 48 | if [ -z "$CHANGELOG" ]; then 49 | CHANGELOG="- Initial release" 50 | fi 51 | 52 | # Create the release body directly in the GitHub output 53 | { 54 | echo "RELEASE_BODY<> $GITHUB_OUTPUT 67 | 68 | # Create GitHub Release 69 | - name: Create GitHub Release 70 | uses: softprops/action-gh-release@v1 71 | with: 72 | tag_name: ${{ github.ref_name }} 73 | name: Release ${{ github.ref_name }} 74 | body: ${{ steps.changelog.outputs.RELEASE_BODY }} 75 | draft: false 76 | prerelease: false 77 | #files: | 78 | # bin/* 79 | env: 80 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 81 | -------------------------------------------------------------------------------- /examples/make/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 3 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 5 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 6 | github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= 7 | github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 8 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 9 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 10 | github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= 11 | github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= 12 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 13 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 15 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 16 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 17 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 18 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 19 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 20 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 21 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 22 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 23 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 24 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 25 | golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= 26 | golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 27 | golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= 28 | golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= 29 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # Schema Generation 2 | 3 | Ophis automatically generates JSON schemas for MCP tools from Cobra commands. 4 | 5 | ## Tool Properties 6 | 7 | - **Name**: Command path with underscores (`kubectl_get_pods`) 8 | - **Description**: From command's Long, Short, and Example fields 9 | - **Input Schema**: Generated from flags and arguments 10 | - **Output Schema**: Standard format (stdout, stderr, exitCode) 11 | 12 | ## Input Schema 13 | 14 | ### Flags 15 | 16 | Each flag becomes a property with: 17 | 18 | **Type mapping:** 19 | 20 | - `bool` → `boolean` 21 | - `int`, `uint` → `integer` 22 | - `float` → `number` 23 | - `string` → `string` 24 | - `string` → `` if the flag has an annotation called `jsonschema` with a value that is the JSON string representation of the schema 25 | - `stringSlice`, `intSlice` → `array` 26 | - `duration`, `ip`, `ipNet` → `string` with pattern validation 27 | 28 | Flags marked as required (via `cmd.MarkFlagRequired()`) are included in the schema's `required` array. Default values are included in the schema, except for empty strings (`""`) and empty arrays (`[]`). 29 | 30 | **Example:** 31 | 32 | ```json 33 | { 34 | "flags": { 35 | "type": "object", 36 | "properties": { 37 | "namespace": { 38 | "type": "string", 39 | "description": "Kubernetes namespace", 40 | "default": "default" 41 | }, 42 | "replicas": { 43 | "type": "integer", 44 | "default": 3 45 | }, 46 | "labels": { 47 | "type": "array", 48 | "items": { "type": "string" } 49 | } 50 | }, 51 | "required": ["namespace"] 52 | } 53 | } 54 | ``` 55 | 56 | Example showing JSON schema: 57 | 58 | ```golang 59 | 60 | type SomeJsonObject struct { 61 | Foo string 62 | Bar int 63 | FooBar struct { 64 | Baz string 65 | } 66 | } 67 | 68 | // generate schema for our object 69 | aJsonObjSchema, err := jsonschema.For[SomeJsonObject](nil) 70 | if err != nil { 71 | // do something better than this in prod 72 | panic(err) 73 | } 74 | bytes, err := aJsonObjSchema.MarshalJSON() 75 | if err != nil { 76 | // do something better than this in prod 77 | panic(err) 78 | } 79 | // now create flag that has a json schema that represents a json object 80 | cmd.Flags().String("a_json_obj", "", "Some JSON Object") 81 | jsonobj := cmd.Flags().Lookup("a_json_obj") 82 | jsonobj.Annotations = make(map[string][]string) 83 | jsonobj.Annotations["jsonschema"] = []string{string(bytes)} 84 | 85 | ``` 86 | 87 | ### Arguments 88 | 89 | Positional arguments are a string array: 90 | 91 | ```json 92 | { 93 | "args": { 94 | "type": "array", 95 | "description": "Positional arguments\nUsage: [NAME] [flags]", 96 | "items": { "type": "string" } 97 | } 98 | } 99 | ``` 100 | 101 | ## Output Schema 102 | 103 | ```json 104 | { 105 | "type": "object", 106 | "properties": { 107 | "stdout": { "type": "string" }, 108 | "stderr": { "type": "string" }, 109 | "exitCode": { "type": "integer" } 110 | } 111 | } 112 | ``` 113 | 114 | ## Export Schemas 115 | 116 | ```bash 117 | ./my-cli mcp tools # Creates mcp-tools.json 118 | ``` 119 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/utils_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/spf13/cobra" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestDeriveServerName(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | path string 16 | expectedName string 17 | }{ 18 | { 19 | name: "simple executable", 20 | path: "/usr/local/bin/myapp", 21 | expectedName: "myapp", 22 | }, 23 | { 24 | name: "executable with extension", 25 | path: "/usr/local/bin/myapp.exe", 26 | expectedName: "myapp", 27 | }, 28 | { 29 | name: "nested path", 30 | path: "/home/user/dev/project/bin/mycli", 31 | expectedName: "mycli", 32 | }, 33 | { 34 | name: "current directory", 35 | path: "./myapp", 36 | expectedName: "myapp", 37 | }, 38 | { 39 | name: "just filename", 40 | path: "kubectl", 41 | expectedName: "kubectl", 42 | }, 43 | } 44 | 45 | for _, tt := range tests { 46 | t.Run(tt.name, func(t *testing.T) { 47 | result := DeriveServerName(tt.path) 48 | assert.Equal(t, tt.expectedName, result) 49 | }) 50 | } 51 | } 52 | 53 | func TestGetCmdPath(t *testing.T) { 54 | tests := []struct { 55 | name string 56 | cmdPath string 57 | expectedPath []string 58 | expectError bool 59 | }{ 60 | { 61 | name: "root mcp command", 62 | cmdPath: "myapp mcp", 63 | expectedPath: []string{"mcp"}, 64 | expectError: false, 65 | }, 66 | { 67 | name: "nested mcp command", 68 | cmdPath: "myapp alpha mcp", 69 | expectedPath: []string{"alpha", "mcp"}, 70 | expectError: false, 71 | }, 72 | { 73 | name: "deeply nested mcp command", 74 | cmdPath: "myapp alpha beta mcp start", 75 | expectedPath: []string{"alpha", "beta", "mcp"}, 76 | expectError: false, 77 | }, 78 | { 79 | name: "no mcp in path", 80 | cmdPath: "myapp start", 81 | expectedPath: nil, 82 | expectError: true, 83 | }, 84 | { 85 | name: "mcp as root returns empty slice", 86 | cmdPath: "mcp start", 87 | expectedPath: []string{}, 88 | expectError: false, 89 | }, 90 | } 91 | 92 | for _, tt := range tests { 93 | t.Run(tt.name, func(t *testing.T) { 94 | // Build a command tree that matches the path 95 | cmd := buildMockCommandTree(tt.cmdPath) 96 | 97 | result, err := GetCmdPath(cmd) 98 | 99 | if tt.expectError { 100 | require.Error(t, err) 101 | assert.Nil(t, result) 102 | } else { 103 | require.NoError(t, err) 104 | assert.Equal(t, tt.expectedPath, result) 105 | } 106 | }) 107 | } 108 | } 109 | 110 | // buildMockCommandTree creates a cobra command tree from a space-separated path. 111 | // The last command in the path is returned. 112 | func buildMockCommandTree(path string) *cobra.Command { 113 | parts := strings.Fields(path) 114 | if len(parts) == 0 { 115 | return nil 116 | } 117 | 118 | root := &cobra.Command{Use: parts[0]} 119 | parent := root 120 | 121 | for i := 1; i < len(parts); i++ { 122 | cmd := &cobra.Command{Use: parts[i]} 123 | parent.AddCommand(cmd) 124 | parent = cmd 125 | } 126 | 127 | return parent 128 | } 129 | -------------------------------------------------------------------------------- /execute.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "os/exec" 10 | "strings" 11 | 12 | "github.com/modelcontextprotocol/go-sdk/mcp" 13 | ) 14 | 15 | var executablePath = initExecPath() 16 | 17 | func initExecPath() string { 18 | path, err := os.Executable() 19 | if err != nil { 20 | panic(fmt.Sprintf("Failed to get executable path: %v", err)) 21 | } 22 | 23 | return path 24 | } 25 | 26 | // Execute runs the underlying CLI command. 27 | func execute(ctx context.Context, request *mcp.CallToolRequest, input ToolInput) (*mcp.CallToolResult, ToolOutput, error) { 28 | name := request.Params.Name 29 | slog.Info("mcp tool request received", "request", name) 30 | 31 | // Build command arguments 32 | args := buildCommandArgs(name, input) 33 | slog.Debug("executing command", 34 | "tool", name, 35 | "input", input, 36 | "args", args, 37 | ) 38 | 39 | // Create exec.Cmd and run it 40 | var stdout, stderr bytes.Buffer 41 | cmd := exec.CommandContext(ctx, executablePath, args...) 42 | cmd.Stdout = &stdout 43 | cmd.Stderr = &stderr 44 | exitCode := 0 45 | 46 | err := cmd.Run() 47 | if err != nil { 48 | // Check if it's an ExitError to get the exit code 49 | if exitErr, ok := err.(*exec.ExitError); ok { 50 | exitCode = exitErr.ExitCode() 51 | } else { 52 | // Non-exit errors (like command not found) 53 | slog.Error("command failed to run", "name", name, "error", err) 54 | return nil, ToolOutput{}, err 55 | } 56 | } 57 | 58 | return nil, ToolOutput{ 59 | StdOut: stdout.String(), 60 | StdErr: stderr.String(), 61 | ExitCode: exitCode, 62 | }, nil 63 | } 64 | 65 | // buildCommandArgs constructs CLI arguments from the MCP request. 66 | func buildCommandArgs(name string, input ToolInput) []string { 67 | // Start with the command path (e.g., "root_sub_command" -> ["root", "sub", "command"]) 68 | // And remove the root command prefix 69 | args := strings.Split(name, "_")[1:] 70 | 71 | // Add flags 72 | flagArgs := buildFlagArgs(input.Flags) 73 | args = append(args, flagArgs...) 74 | 75 | // Add positional arguments 76 | return append(args, input.Args...) 77 | } 78 | 79 | // buildFlagArgs converts MCP flags to CLI flag arguments. 80 | func buildFlagArgs(flagMap map[string]any) []string { 81 | var args []string 82 | 83 | for name, value := range flagMap { 84 | if name == "" || value == nil { 85 | continue 86 | } 87 | 88 | if items, ok := value.([]any); ok { 89 | for _, item := range items { 90 | args = append(args, parseFlagArgValue(name, item)...) 91 | } 92 | 93 | continue 94 | } 95 | 96 | if mapVal, ok := value.(map[string]any); ok { 97 | for k, v := range mapVal { 98 | args = append(args, fmt.Sprintf("--%s", name), fmt.Sprintf("%s=%v", k, v)) 99 | } 100 | 101 | continue 102 | } 103 | 104 | args = append(args, parseFlagArgValue(name, value)...) 105 | } 106 | 107 | return args 108 | } 109 | 110 | func parseFlagArgValue(name string, value any) (retVal []string) { 111 | if value != nil { 112 | switch v := value.(type) { 113 | case bool: 114 | if v { 115 | retVal = append(retVal, fmt.Sprintf("--%s", name)) 116 | } 117 | default: 118 | retVal = append(retVal, fmt.Sprintf("--%s", name), fmt.Sprintf("%v", value)) 119 | } 120 | } 121 | 122 | return retVal 123 | } 124 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package ophis transforms Cobra CLI applications into MCP (Model Context Protocol) servers, 2 | // enabling AI assistants to interact with command-line tools. 3 | // 4 | // Ophis automatically converts existing Cobra commands into MCP tools, handling 5 | // protocol complexity, command execution, and tool registration. 6 | // 7 | // # Basic Usage 8 | // 9 | // Add MCP server functionality to your existing Cobra CLI application: 10 | // 11 | // package main 12 | // 13 | // import ( 14 | // "os" 15 | // "github.com/njayp/ophis" 16 | // ) 17 | // 18 | // func main() { 19 | // rootCmd := createMyRootCommand() 20 | // 21 | // // Add MCP server commands 22 | // rootCmd.AddCommand(ophis.Command(nil)) 23 | // 24 | // if err := rootCmd.Execute(); err != nil { 25 | // os.Exit(1) 26 | // } 27 | // } 28 | // 29 | // This adds the following subcommands to your CLI: 30 | // - mcp start: Start the MCP server 31 | // - mcp tools: List available tools 32 | // - mcp claude enable/disable/list: Manage Claude Desktop integration 33 | // - mcp vscode enable/disable/list: Manage VSCode integration 34 | // 35 | // # Integration 36 | // 37 | // Enable MCP support in Claude Desktop or VSCode: 38 | // 39 | // # Claude Desktop 40 | // ./my-cli mcp claude enable 41 | // 42 | // # VSCode (requires Copilot in Agent Mode) 43 | // ./my-cli mcp vscode enable 44 | // 45 | // # Configuration 46 | // 47 | // The Config struct provides fine-grained control over which commands and flags 48 | // are exposed as MCP tools through a powerful selector system. 49 | // 50 | // Basic filters are always applied automatically: 51 | // - Hidden and deprecated commands/flags are excluded 52 | // - Commands without executable functions are excluded 53 | // - Built-in commands (mcp, help, completion) are excluded 54 | // 55 | // Your selectors add additional filtering on top: 56 | // 57 | // config := &ophis.Config{ 58 | // // Selectors are evaluated in order - first match wins 59 | // Selectors: []ophis.Selector{ 60 | // { 61 | // CmdSelector: ophis.AllowCmdsContaining("get", "list"), 62 | // // Control which flags are included for matched commands 63 | // LocalFlagSelector: ophis.AllowFlags("namespace", "output"), 64 | // InheritedFlagSelector: ophis.NoFlags, // Exclude persistent flags 65 | // // Optional: Add middleware to wrap execution 66 | // Middleware: func(ctx context.Context, req *mcp.CallToolRequest, in ophis.ToolInput, next func(context.Context, *mcp.CallToolRequest, ophis.ToolInput) (*mcp.CallToolResult, ophis.ToolOutput, error)) (*mcp.CallToolResult, ophis.ToolOutput, error) { 67 | // // Pre-execution: timeout, logging, auth checks, etc. 68 | // ctx, cancel := context.WithTimeout(ctx, time.Minute) 69 | // defer cancel() 70 | // // Execute the command 71 | // res, out, err := next(ctx, req, in) 72 | // // Post-execution: error handling, response filtering, metrics 73 | // return res, out, err 74 | // }, 75 | // }, 76 | // { 77 | // CmdSelector: ophis.AllowCmds("mycli delete"), 78 | // LocalFlagSelector: ophis.ExcludeFlags("all", "force"), 79 | // InheritedFlagSelector: ophis.NoFlags, 80 | // }, 81 | // }, 82 | // 83 | // // Configure logging 84 | // SloggerOptions: &slog.HandlerOptions{ 85 | // Level: slog.LevelDebug, 86 | // }, 87 | // } 88 | // 89 | // The selector system allows different commands to have different flag filtering 90 | // rules and middleware, enabling precise control over the exposed tool surface. 91 | // Each selector defines which commands to match, which flags to include, and optional 92 | // middleware for wrapping execution with timeouts, logging, and response filtering. 93 | package ophis 94 | -------------------------------------------------------------------------------- /test/tools.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "slices" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/google/jsonschema-go/jsonschema" 11 | "github.com/modelcontextprotocol/go-sdk/mcp" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | // GetTools runs `mcp tools` command and returns the parsed list of tools 16 | // It fails if ophis.Command is not a root level subcommand 17 | func GetTools(t *testing.T, cmd *cobra.Command) []*mcp.Tool { 18 | cmd.SetArgs([]string{"mcp", "tools"}) 19 | err := cmd.Execute() 20 | if err != nil { 21 | t.Fatalf("Failed to generate tools: %v", err) 22 | } 23 | 24 | // Verify the mcp-tools.json file was created and contains valid JSON 25 | data, err := os.ReadFile("mcp-tools.json") 26 | if err != nil { 27 | t.Fatalf("Failed to read mcp-tools.json: %v", err) 28 | } 29 | 30 | var tools []*mcp.Tool 31 | if err := json.Unmarshal(data, &tools); err != nil { 32 | t.Fatalf("Failed to unmarshal mcp-tools.json: %v", err) 33 | } 34 | 35 | // Clean up the generated file 36 | if err := os.Remove("mcp-tools.json"); err != nil { 37 | t.Logf("Warning: failed to remove mcp-tools.json: %v", err) 38 | } 39 | 40 | return tools 41 | } 42 | 43 | // GetInputSchema extracts and returns the input schema from a tool 44 | func GetInputSchema(t *testing.T, tool *mcp.Tool) *jsonschema.Schema { 45 | if tool.InputSchema == nil { 46 | t.Fatalf("Tool %q has no input schema", tool.Name) 47 | } 48 | 49 | data, err := json.Marshal(tool.InputSchema) 50 | if err != nil { 51 | t.Fatalf("Tool %q: failed to marshal input schema: %v", tool.Name, err) 52 | } 53 | 54 | schema := &jsonschema.Schema{} 55 | if err := json.Unmarshal(data, schema); err != nil { 56 | t.Fatalf("Tool %q: failed to unmarshal input schema into *jsonschema.Schema: %v (type was %T)", 57 | tool.Name, err, tool.InputSchema) 58 | } 59 | 60 | return schema 61 | } 62 | 63 | // ToolNames checks that the provided tools match the expectedNames 64 | func ToolNames(t *testing.T, tools []*mcp.Tool, expectedNames ...string) { 65 | if len(tools) != len(expectedNames) { 66 | t.Errorf("expected %v tools, got %v", len(expectedNames), len(tools)) 67 | } 68 | 69 | for _, tool := range tools { 70 | if !slices.Contains(expectedNames, tool.Name) { 71 | t.Errorf("Tool name not expected: %q", tool.Name) 72 | } 73 | } 74 | } 75 | 76 | // CmdPathsToToolNames converts command names with spaces to tool names with underscores 77 | func CmdPathsToToolNames(paths []string) []string { 78 | names := make([]string, 0, len(paths)) 79 | for _, path := range paths { 80 | names = append(names, strings.ReplaceAll(path, " ", "_")) 81 | } 82 | 83 | return names 84 | } 85 | 86 | // Tools runs `mcp tools` and checks the output tool names against expectedNames 87 | // It fails if ophis.Command is not a root level subcommand 88 | func Tools(t *testing.T, cmd *cobra.Command, expectedNames ...string) { 89 | tools := GetTools(t, cmd) 90 | 91 | t.Run("Expected Tools", func(t *testing.T) { 92 | ToolNames(t, tools, expectedNames...) 93 | }) 94 | 95 | t.Run("Valid JSON Schema", func(t *testing.T) { 96 | // Check that each tool's input schema is valid JSON Schema 97 | for _, tool := range tools { 98 | if tool.InputSchema != nil { 99 | schema := GetInputSchema(t, tool) 100 | if schema.Type != "object" { 101 | t.Errorf("Tool %q: expected schema type 'object', got %q", tool.Name, schema.Type) 102 | } 103 | 104 | // Validate "flags" property if present 105 | if prop, ok := schema.Properties["flags"]; ok { 106 | if prop.Type != "object" { 107 | t.Errorf("Tool %q: expected 'flags' property type 'object', got %q", tool.Name, prop.Type) 108 | } 109 | } 110 | 111 | // Validate "args" property if present 112 | if prop, ok := schema.Properties["args"]; ok { 113 | if prop.Type != "array" || prop.Items.Type != "string" { 114 | t.Errorf("Tool %q: expected 'args' property type 'array', got %q, %q", tool.Name, prop.Type, prop.Items.Type) 115 | } 116 | } 117 | } 118 | } 119 | }) 120 | } 121 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 2 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 3 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 4 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 6 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 7 | github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q= 8 | github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= 9 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 10 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 11 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 12 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 13 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 14 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 15 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 16 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 17 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 18 | github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= 19 | github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= 20 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 21 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 22 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 24 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 25 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 26 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 27 | github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= 28 | github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 29 | github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 30 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 31 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 32 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 33 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 34 | github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= 35 | github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= 36 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 37 | golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= 38 | golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= 39 | golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= 40 | golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= 41 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 42 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 43 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 44 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 45 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Project Logo](./logo.png) 2 | 3 | **Transform any Cobra CLI into an MCP server** 4 | 5 | Ophis automatically converts your Cobra commands into MCP tools, and provides CLI commands for integration with Claude Desktop, VSCode, and Cursor. 6 | 7 | ## Quick Start 8 | 9 | ### Install 10 | 11 | ```bash 12 | go get github.com/njayp/ophis 13 | ``` 14 | 15 | ### Add to your CLI 16 | 17 | ```go 18 | package main 19 | 20 | import ( 21 | "os" 22 | "github.com/njayp/ophis" 23 | ) 24 | 25 | func main() { 26 | rootCmd := createMyRootCommand() 27 | rootCmd.AddCommand(ophis.Command(nil)) 28 | 29 | if err := rootCmd.Execute(); err != nil { 30 | os.Exit(1) 31 | } 32 | } 33 | ``` 34 | 35 | ### Enable in Claude Desktop, VSCode, or Cursor 36 | 37 | ```bash 38 | # Claude Desktop 39 | ./my-cli mcp claude enable 40 | # Restart Claude Desktop 41 | 42 | # VSCode (requires Copilot in Agent Mode) 43 | ./my-cli mcp vscode enable 44 | 45 | # Cursor 46 | ./my-cli mcp cursor enable 47 | ``` 48 | 49 | Your CLI commands are now available as MCP tools! 50 | 51 | ### Stream over HTTP 52 | 53 | Expose your MCP server over HTTP for remote access: 54 | 55 | ```bash 56 | ./my-cli mcp stream --host localhost --port 8080 57 | ``` 58 | 59 | ## Commands 60 | 61 | The `ophis.Command(nil)` adds these subcommands to your CLI: 62 | 63 | ``` 64 | mcp 65 | ├── start # Start MCP server on stdio 66 | ├── stream # Stream MCP server over HTTP 67 | ├── tools # Export available MCP tools as JSON 68 | ├── claude 69 | │ ├── enable # Add server to Claude Desktop config 70 | │ ├── disable # Remove server from Claude Desktop config 71 | │ └── list # List Claude Desktop MCP servers 72 | ├── vscode 73 | │ ├── enable # Add server to VSCode config 74 | │ ├── disable # Remove server from VSCode config 75 | │ └── list # List VSCode MCP servers 76 | └── cursor 77 | ├── enable # Add server to Cursor config 78 | ├── disable # Remove server from Cursor config 79 | └── list # List Cursor MCP servers 80 | ``` 81 | 82 | ## Configuration 83 | 84 | Control which commands and flags are exposed as MCP tools using selectors. By default, all commands and flags are exposed (except hidden/deprecated). 85 | 86 | ```go 87 | config := &ophis.Config{ 88 | Selectors: []ophis.Selector{ 89 | { 90 | CmdSelector: ophis.AllowCmdsContaining("get", "list"), 91 | LocalFlagSelector: ophis.ExcludeFlags("token", "secret"), 92 | InheritedFlagSelector: ophis.NoFlags, // Exclude persistent flags 93 | 94 | // Middleware wraps command execution 95 | Middleware: func(ctx context.Context, req *mcp.CallToolRequest, in ophis.ToolInput, next func(context.Context, *mcp.CallToolRequest, ophis.ToolInput) (*mcp.CallToolResult, ophis.ToolOutput, error)) (*mcp.CallToolResult, ophis.ToolOutput, error) { 96 | ctx, cancel := context.WithTimeout(ctx, time.Minute) 97 | defer cancel() 98 | return next(ctx, req, in) 99 | }, 100 | }, 101 | }, 102 | } 103 | 104 | rootCmd.AddCommand(ophis.Command(config)) 105 | ``` 106 | 107 | See [docs/config.md](docs/config.md) for detailed configuration options. 108 | 109 | ## How It Works 110 | 111 | Ophis bridges Cobra commands and the Model Context Protocol: 112 | 113 | 1. **Command Discovery**: Recursively walks your Cobra command tree 114 | 2. **Schema Generation**: Creates JSON schemas from command flags and arguments ([docs/schema.md](docs/schema.md)) 115 | 3. **Tool Execution**: Spawns your CLI as a subprocess and captures output ([docs/execution.md](docs/execution.md)) 116 | 117 | ## Examples 118 | 119 | Build all examples: 120 | 121 | ```bash 122 | make build 123 | ``` 124 | 125 | Example projects using Ophis: 126 | 127 | - [kubectl](./examples/kubectl/) 128 | - [helm](./examples/helm/) 129 | - [argocd](./examples/argocd/) 130 | - [make](./examples/make/) 131 | 132 | ## Contributing 133 | 134 | Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). 135 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | 3 | package main 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | // All runs 'make all' for each example directory in parallel 15 | func All() error { 16 | return runMakeInExamples("all") 17 | } 18 | 19 | // Build runs 'make build' for each example directory in parallel 20 | func Build() error { 21 | return runMakeInExamples("build") 22 | } 23 | 24 | func Test() error { 25 | return runMakeInExamples("test") 26 | } 27 | 28 | // Claude runs './bin/ mcp claude enable' for every binary in bin/ 29 | func Claude() error { 30 | return enableMCP("claude") 31 | } 32 | 33 | // Vscode runs './bin/ mcp vscode enable' for every binary in bin/ 34 | func Vscode() error { 35 | return enableMCP("vscode") 36 | } 37 | 38 | // runMakeInExamples runs a make target in all example directories in parallel 39 | func runMakeInExamples(target string) error { 40 | dirs, err := findExampleDirs() 41 | if err != nil { 42 | return err 43 | } 44 | 45 | return runParallel(dirs, func(dir string) error { 46 | cmd := exec.Command("make", target) 47 | cmd.Dir = dir 48 | cmd.Stdout = os.Stdout 49 | cmd.Stderr = os.Stderr 50 | return cmd.Run() 51 | }) 52 | } 53 | 54 | // enableMCP runs './bin/ mcp enable' for every binary in bin/ 55 | func enableMCP(platform string) error { 56 | binaries, err := findExecutables("bin") 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if err := runParallel(binaries, func(binary string) error { 62 | cmd := exec.Command(binary, "mcp", platform, "enable") 63 | cmd.Stdout = os.Stdout 64 | cmd.Stderr = os.Stderr 65 | return cmd.Run() 66 | }); err != nil { 67 | return err 68 | } 69 | 70 | fmt.Printf("\n🎉 All binaries are now available in %s!\n", platform) 71 | fmt.Printf("⚠️ Please restart %s for changes to take effect.\n", platform) 72 | return nil 73 | } 74 | 75 | // findExampleDirs returns all example directories with makefiles 76 | func findExampleDirs() ([]string, error) { 77 | entries, err := os.ReadDir("examples") 78 | if err != nil { 79 | return nil, fmt.Errorf("failed to read examples directory: %w", err) 80 | } 81 | 82 | var dirs []string 83 | for _, entry := range entries { 84 | if !entry.IsDir() { 85 | continue 86 | } 87 | dir := filepath.Join("examples", entry.Name()) 88 | if _, err := os.Stat(filepath.Join(dir, "makefile")); err == nil { 89 | dirs = append(dirs, dir) 90 | } 91 | } 92 | return dirs, nil 93 | } 94 | 95 | // findExecutables returns all executable files in a directory 96 | func findExecutables(dir string) ([]string, error) { 97 | if _, err := os.Stat(dir); os.IsNotExist(err) { 98 | return nil, fmt.Errorf("%s directory does not exist - run 'mage build' first", dir) 99 | } 100 | 101 | entries, err := os.ReadDir(dir) 102 | if err != nil { 103 | return nil, fmt.Errorf("failed to read %s directory: %w", dir, err) 104 | } 105 | 106 | var executables []string 107 | for _, entry := range entries { 108 | if entry.IsDir() { 109 | continue 110 | } 111 | info, err := entry.Info() 112 | if err != nil { 113 | continue 114 | } 115 | if info.Mode()&0o111 != 0 { 116 | executables = append(executables, filepath.Join(dir, entry.Name())) 117 | } 118 | } 119 | return executables, nil 120 | } 121 | 122 | // runParallel runs a function on multiple items in parallel 123 | func runParallel(items []string, fn func(string) error) error { 124 | if len(items) == 0 { 125 | return fmt.Errorf("no items to process") 126 | } 127 | 128 | var wg sync.WaitGroup 129 | errChan := make(chan error, len(items)) 130 | 131 | for _, item := range items { 132 | wg.Add(1) 133 | go func(item string) { 134 | defer wg.Done() 135 | if err := fn(item); err != nil { 136 | errChan <- fmt.Errorf("%s: %w", item, err) 137 | } 138 | }(item) 139 | } 140 | 141 | wg.Wait() 142 | close(errChan) 143 | 144 | var errors []string 145 | for err := range errChan { 146 | errors = append(errors, err.Error()) 147 | } 148 | 149 | if len(errors) > 0 { 150 | return fmt.Errorf("failed:\n%s", strings.Join(errors, "\n")) 151 | } 152 | return nil 153 | } 154 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | ## Selectors 4 | 5 | Selectors control which commands and flags become MCP tools. Ophis evaluates selectors in order and uses the **first matching selector** for each command. If no selectors match a command, the command is not exposed as a MCP tool. 6 | 7 | ### Default Behavior 8 | 9 | - If `Config.Selectors` is nil/empty, all commands and flags are exposed 10 | - If `CmdSelector` is nil, the selector matches all commands 11 | - If `LocalFlagSelector` or `InheritedFlagSelector` is nil, all flags are included 12 | 13 | ### Automatic Filtering 14 | 15 | Always filtered regardless of configuration: 16 | 17 | - Hidden and deprecated commands/flags 18 | - Non-runnable commands (no Run/RunE) 19 | - Built-in commands (mcp, help, completion) 20 | 21 | ## Examples 22 | 23 | ### Expose Specific Commands 24 | 25 | ```go 26 | config := &ophis.Config{ 27 | Selectors: []ophis.Selector{ 28 | { 29 | CmdSelector: ophis.AllowCmds("kubectl get", "kubectl describe"), 30 | }, 31 | }, 32 | } 33 | ``` 34 | 35 | ### Exclude Sensitive Flags 36 | 37 | ```go 38 | config := &ophis.Config{ 39 | Selectors: []ophis.Selector{ 40 | { 41 | LocalFlagSelector: ophis.ExcludeFlags("token", "password"), 42 | InheritedFlagSelector: ophis.NoFlags, 43 | }, 44 | }, 45 | } 46 | ``` 47 | 48 | ### Different Rules per Command Type 49 | 50 | ```go 51 | config := &ophis.Config{ 52 | Selectors: []ophis.Selector{ 53 | { 54 | // Read ops: timeout 55 | CmdSelector: ophis.AllowCmdsContaining("get", "list"), 56 | Middleware: func(ctx context.Context, req *mcp.CallToolRequest, in ophis.ToolInput, next func(context.Context, *mcp.CallToolRequest, ophis.ToolInput) (*mcp.CallToolResult, ophis.ToolOutput, error)) (*mcp.CallToolResult, ophis.ToolOutput, error) { 57 | ctx, cancel := context.WithTimeout(ctx, time.Minute) 58 | defer cancel() 59 | return next(ctx, req, in) 60 | }, 61 | }, 62 | { 63 | // Write ops: restrict flags 64 | CmdSelector: ophis.AllowCmdsContaining("delete", "apply"), 65 | LocalFlagSelector: ophis.ExcludeFlags("force", "all"), 66 | }, 67 | }, 68 | } 69 | ``` 70 | 71 | ## Selector Functions 72 | 73 | ### Commands 74 | 75 | - `AllowCmds(cmds ...string)` - Exact matches 76 | - `ExcludeCmds(cmds ...string)` - Exact exclusions 77 | - `AllowCmdsContaining(substrings ...string)` - Contains any 78 | - `ExcludeCmdsContaining(substrings ...string)` - Excludes all 79 | 80 | ### Flags 81 | 82 | - `AllowFlags(names ...string)` - Include only these 83 | - `ExcludeFlags(names ...string)` - Exclude these 84 | - `NoFlags` - Exclude all 85 | 86 | ### Custom Selector Functions 87 | 88 | ```go 89 | config := &ophis.Config{ 90 | Selectors: []ophis.Selector{ 91 | { 92 | CmdSelector: func(cmd *cobra.Command) bool { 93 | // Only expose commands that have been annotated as "mcp" 94 | return cmd.Annotations["mcp"] == "true" 95 | }, 96 | 97 | LocalFlagSelector: func(flag *pflag.Flag) bool { 98 | return flag.Annotations["mcp"] == "true" 99 | }, 100 | 101 | InheritedFlagSelector: func(flag *pflag.Flag) bool { 102 | return flag.Annotations["mcp"] == "true" 103 | }, 104 | }, 105 | }, 106 | } 107 | ``` 108 | 109 | ## Middleware 110 | 111 | Wrap execution with custom logic: 112 | 113 | ```go 114 | Middleware: func(ctx context.Context, req *mcp.CallToolRequest, in ophis.ToolInput, next func(context.Context, *mcp.CallToolRequest, ophis.ToolInput) (*mcp.CallToolResult, ophis.ToolOutput, error)) (*mcp.CallToolResult, ophis.ToolOutput, error) { 115 | // Pre-execution: Add timeout 116 | ctx, cancel := context.WithTimeout(ctx, 30*time.Second) 117 | defer cancel() 118 | 119 | // Pre-execution: Validate input 120 | if len(in.Args) > 10 { 121 | in.Args = in.Args[:10] 122 | } 123 | 124 | // Execute the command 125 | res, out, err := next(ctx, req, in) 126 | 127 | // Post-execution: Filter output 128 | if strings.Contains(out.StdOut, "SECRET") { 129 | out.StdOut = "[REDACTED]" 130 | } 131 | 132 | return res, out, err 133 | } 134 | ``` 135 | 136 | ## Logging 137 | 138 | ```go 139 | config := &ophis.Config{ 140 | SloggerOptions: &slog.HandlerOptions{ 141 | Level: slog.LevelDebug, 142 | }, 143 | } 144 | ``` 145 | 146 | Or via CLI: 147 | 148 | ```bash 149 | ./my-cli mcp vscode enable --log-level debug 150 | ``` 151 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/modelcontextprotocol/go-sdk/mcp" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | // Config customizes MCP server behavior and command-to-tool conversion. 17 | type Config struct { 18 | // Selectors defines rules for converting commands to MCP tools. 19 | // Each selector specifies which commands to match and which flags to include. 20 | // 21 | // Basic safety filters are always applied first: 22 | // - Hidden/deprecated commands and flags are excluded 23 | // - Non-runnable commands are excluded 24 | // - Built-in commands (mcp, help, completion) are excluded 25 | // 26 | // Then selectors are evaluated in order for each command: 27 | // 1. The first selector whose CmdSelector returns true is used 28 | // 2. That selector's FlagSelector determines which flags are included 29 | // 3. If no selectors match, the command is not exposed as a tool 30 | // 31 | // If nil or empty, defaults to exposing all commands with all flags. 32 | Selectors []Selector 33 | 34 | // SloggerOptions configures logging to stderr. 35 | // Default: Info level logging. 36 | SloggerOptions *slog.HandlerOptions 37 | 38 | // ServerOptions for the underlying MCP server. 39 | ServerOptions *mcp.ServerOptions 40 | 41 | // Transport for stdio transport configuration. 42 | Transport mcp.Transport 43 | 44 | server *mcp.Server 45 | tools []*mcp.Tool 46 | } 47 | 48 | func (c *Config) serveStdio(cmd *cobra.Command) error { 49 | if c.Transport == nil { 50 | c.Transport = &mcp.StdioTransport{} 51 | } 52 | 53 | c.registerTools(cmd) 54 | return c.server.Run(cmd.Context(), c.Transport) 55 | } 56 | 57 | func (c *Config) serveHTTP(cmd *cobra.Command, addr string) error { 58 | c.registerTools(cmd) 59 | 60 | // Create the streamable HTTP handler. 61 | handler := mcp.NewStreamableHTTPHandler(func(_ *http.Request) *mcp.Server { 62 | return c.server 63 | }, nil) 64 | 65 | server := &http.Server{Addr: addr, Handler: handler} 66 | 67 | // Shutdown gracefully 68 | ch := make(chan os.Signal, 1) 69 | signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM) 70 | go func() { 71 | select { 72 | case <-ch: 73 | case <-cmd.Context().Done(): 74 | } 75 | signal.Stop(ch) 76 | 77 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 78 | defer cancel() 79 | if err := server.Shutdown(ctx); err != nil { 80 | slog.Error("error shutting down server", "error", err) 81 | } 82 | }() 83 | 84 | cmd.Printf("MCP server listening on address %q\n", addr) 85 | return server.ListenAndServe() 86 | } 87 | 88 | // registerTools fully initializes a MCP server and populates c.tools 89 | func (c *Config) registerTools(cmd *cobra.Command) { 90 | // slog to stderr 91 | handler := slog.NewTextHandler(os.Stderr, c.SloggerOptions) 92 | slog.SetDefault(slog.New(handler)) 93 | 94 | // get root cmd 95 | rootCmd := cmd 96 | for rootCmd.Parent() != nil { 97 | rootCmd = rootCmd.Parent() 98 | } 99 | 100 | // make server 101 | c.server = mcp.NewServer(&mcp.Implementation{ 102 | Name: rootCmd.Name(), 103 | Version: rootCmd.Version, 104 | }, c.ServerOptions) 105 | 106 | // ensure at least one selector exists for tool creation logic 107 | if len(c.Selectors) == 0 { 108 | c.Selectors = []Selector{{}} 109 | } 110 | 111 | // register tools 112 | c.registerToolsRecursive(rootCmd) 113 | } 114 | 115 | // registerTools explores a cmd tree, making tools recursively out of the provided cmd and its children 116 | func (c *Config) registerToolsRecursive(cmd *cobra.Command) { 117 | // register all subcommands 118 | for _, subCmd := range cmd.Commands() { 119 | c.registerToolsRecursive(subCmd) 120 | } 121 | 122 | // apply basic filters 123 | if cmdFilter(cmd) { 124 | return 125 | } 126 | 127 | // cycle through selectors until one matches the cmd 128 | for i, s := range c.Selectors { 129 | if s.CmdSelector != nil && !s.CmdSelector(cmd) { 130 | continue 131 | } 132 | 133 | // create tool from cmd 134 | tool := s.createToolFromCmd(cmd) 135 | slog.Debug("created tool", "tool_name", tool.Name, "selector_index", i) 136 | 137 | // register tool with server 138 | mcp.AddTool(c.server, tool, s.execute) 139 | 140 | // add tool to manager's tool list (for `tools` command) 141 | c.tools = append(c.tools, tool) 142 | 143 | // only the first matching selector is used 144 | break 145 | } 146 | } 147 | 148 | // cmdFilter returns true if cmd should be filtered out 149 | func cmdFilter(cmd *cobra.Command) bool { 150 | if cmd.Hidden || cmd.Deprecated != "" { 151 | return true 152 | } 153 | 154 | if cmd.Run == nil && cmd.RunE == nil && cmd.PreRun == nil && cmd.PreRunE == nil { 155 | return true 156 | } 157 | 158 | return AllowCmdsContaining("mcp", "help", "completion")(cmd) 159 | } 160 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/claude/config_test.go: -------------------------------------------------------------------------------- 1 | package claude 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestConfig(t *testing.T) { 10 | t.Run("AddServer adds new server", func(t *testing.T) { 11 | config := &Config{} 12 | server := Server{ 13 | Command: "/usr/local/bin/myapp", 14 | Args: []string{"mcp", "start"}, 15 | } 16 | 17 | config.AddServer("test", server) 18 | 19 | assert.True(t, config.HasServer("test")) 20 | assert.Equal(t, 1, len(config.Servers)) 21 | }) 22 | 23 | t.Run("AddServer initializes map if nil", func(t *testing.T) { 24 | config := &Config{} 25 | assert.Nil(t, config.Servers) 26 | 27 | server := Server{ 28 | Command: "/usr/local/bin/myapp", 29 | } 30 | 31 | config.AddServer("test", server) 32 | assert.NotNil(t, config.Servers) 33 | assert.True(t, config.HasServer("test")) 34 | }) 35 | 36 | t.Run("AddServer updates existing server", func(t *testing.T) { 37 | config := &Config{} 38 | 39 | originalServer := Server{ 40 | Command: "/usr/local/bin/myapp", 41 | Args: []string{"start"}, 42 | } 43 | config.AddServer("test", originalServer) 44 | 45 | updatedServer := Server{ 46 | Command: "/usr/local/bin/myapp", 47 | Args: []string{"start", "--verbose"}, 48 | } 49 | config.AddServer("test", updatedServer) 50 | 51 | assert.Equal(t, 1, len(config.Servers)) 52 | assert.Equal(t, 2, len(config.Servers["test"].Args)) 53 | }) 54 | 55 | t.Run("HasServer returns false for non-existent server", func(t *testing.T) { 56 | config := &Config{ 57 | Servers: map[string]Server{ 58 | "server1": {Command: "/bin/app"}, 59 | }, 60 | } 61 | 62 | assert.False(t, config.HasServer("non-existent")) 63 | assert.True(t, config.HasServer("server1")) 64 | }) 65 | 66 | t.Run("RemoveServer removes existing server", func(t *testing.T) { 67 | config := &Config{ 68 | Servers: map[string]Server{ 69 | "server1": {Command: "/bin/app1"}, 70 | "server2": {Command: "/bin/app2"}, 71 | }, 72 | } 73 | 74 | config.RemoveServer("server1") 75 | 76 | assert.False(t, config.HasServer("server1")) 77 | assert.True(t, config.HasServer("server2")) 78 | assert.Equal(t, 1, len(config.Servers)) 79 | }) 80 | 81 | t.Run("RemoveServer handles non-existent server", func(t *testing.T) { 82 | config := &Config{ 83 | Servers: map[string]Server{ 84 | "server1": {Command: "/bin/app"}, 85 | }, 86 | } 87 | 88 | config.RemoveServer("non-existent") 89 | 90 | assert.Equal(t, 1, len(config.Servers)) 91 | assert.True(t, config.HasServer("server1")) 92 | }) 93 | 94 | t.Run("Multiple servers can be managed", func(t *testing.T) { 95 | config := &Config{} 96 | 97 | servers := []struct { 98 | name string 99 | server Server 100 | }{ 101 | {"kubectl", Server{Command: "/usr/local/bin/kubectl"}}, 102 | {"helm", Server{Command: "/usr/local/bin/helm"}}, 103 | {"argocd", Server{Command: "/usr/local/bin/argocd"}}, 104 | } 105 | 106 | for _, s := range servers { 107 | config.AddServer(s.name, s.server) 108 | } 109 | 110 | assert.Equal(t, 3, len(config.Servers)) 111 | for _, s := range servers { 112 | assert.True(t, config.HasServer(s.name)) 113 | } 114 | 115 | config.RemoveServer("helm") 116 | assert.Equal(t, 2, len(config.Servers)) 117 | assert.False(t, config.HasServer("helm")) 118 | }) 119 | } 120 | 121 | func TestMCPServer(t *testing.T) { 122 | t.Run("Server with command only", func(t *testing.T) { 123 | server := Server{ 124 | Command: "/usr/local/bin/myapp", 125 | } 126 | 127 | assert.Equal(t, "/usr/local/bin/myapp", server.Command) 128 | assert.Empty(t, server.Args) 129 | assert.Empty(t, server.Env) 130 | }) 131 | 132 | t.Run("Server with args", func(t *testing.T) { 133 | server := Server{ 134 | Command: "/usr/local/bin/myapp", 135 | Args: []string{"mcp", "start", "--log-level", "debug"}, 136 | } 137 | 138 | assert.Equal(t, 4, len(server.Args)) 139 | assert.Equal(t, "mcp", server.Args[0]) 140 | assert.Equal(t, "debug", server.Args[3]) 141 | }) 142 | 143 | t.Run("Server with environment variables", func(t *testing.T) { 144 | server := Server{ 145 | Command: "/usr/local/bin/myapp", 146 | Env: map[string]string{ 147 | "DEBUG": "true", 148 | "LOG_FILE": "/var/log/app.log", 149 | }, 150 | } 151 | 152 | assert.Equal(t, 2, len(server.Env)) 153 | assert.Equal(t, "true", server.Env["DEBUG"]) 154 | assert.Equal(t, "/var/log/app.log", server.Env["LOG_FILE"]) 155 | }) 156 | 157 | t.Run("Server with all fields", func(t *testing.T) { 158 | server := Server{ 159 | Command: "/usr/local/bin/myapp", 160 | Args: []string{"mcp", "start"}, 161 | Env: map[string]string{ 162 | "API_KEY": "secret", 163 | }, 164 | } 165 | 166 | assert.NotEmpty(t, server.Command) 167 | assert.NotEmpty(t, server.Args) 168 | assert.NotEmpty(t, server.Env) 169 | }) 170 | } 171 | -------------------------------------------------------------------------------- /internal/bridge/flags/flags.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | 7 | "github.com/google/jsonschema-go/jsonschema" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | // isFlagRequired checks if a flag has been marked as required by Cobra. 13 | // Cobra uses the BashCompOneRequiredFlag annotation to track required flags. 14 | func isFlagRequired(flag *pflag.Flag) bool { 15 | if flag.Annotations == nil { 16 | return false 17 | } 18 | 19 | // Check if the flag has the required annotation 20 | // The constant is defined as "cobra_annotation_bash_completion_one_required_flag" in Cobra 21 | if val, ok := flag.Annotations[cobra.BashCompOneRequiredFlag]; ok { 22 | // The annotation is present if the flag is required 23 | return len(val) > 0 && val[0] == "true" 24 | } 25 | 26 | return false 27 | } 28 | 29 | // AddFlagToSchema adds a single flag to the schema properties. 30 | func AddFlagToSchema(schema *jsonschema.Schema, flag *pflag.Flag) { 31 | flagSchema := &jsonschema.Schema{ 32 | Description: flag.Usage, 33 | } 34 | 35 | // Check if flag is marked as required in its annotations 36 | // Cobra uses the BashCompOneRequiredFlag annotation to mark required flags 37 | if isFlagRequired(flag) { 38 | // Mark the flag as required in the schema 39 | if schema.Required == nil { 40 | schema.Required = []string{} 41 | } 42 | 43 | schema.Required = append(schema.Required, flag.Name) 44 | } 45 | 46 | // Set appropriate JSON schema type based on flag type 47 | t := flag.Value.Type() 48 | switch t { 49 | case "bool": 50 | flagSchema.Type = "boolean" 51 | case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "count": 52 | flagSchema.Type = "integer" 53 | case "float32", "float64": 54 | flagSchema.Type = "number" 55 | case "string": 56 | if flag.Annotations != nil { 57 | if schemaStrArray, ok := flag.Annotations["jsonschema"]; ok { 58 | if len(schemaStrArray) == 0 { 59 | slog.Warn(fmt.Sprintf("No value for jsonschema annotation for flag %s, treating as type string", flag.Name)) 60 | flagSchema.Type = "string" 61 | } else { 62 | var aSchema jsonschema.Schema 63 | err := aSchema.UnmarshalJSON([]byte(schemaStrArray[0])) 64 | if err != nil { 65 | slog.Error(fmt.Sprintf("Error when decoding JSON schema for flag %s (%v), treating as type string", flag.Name, err)) 66 | flagSchema.Type = "string" 67 | } else { 68 | flagSchema = &aSchema 69 | } 70 | } 71 | } else { 72 | slog.Debug(fmt.Sprintf("No annotation called jsonschema for flag %s, treating as type string", flag.Name)) 73 | flagSchema.Type = "string" 74 | } 75 | } else { 76 | flagSchema.Type = "string" 77 | } 78 | 79 | case "stringSlice", "stringArray": 80 | flagSchema.Type = "array" 81 | flagSchema.Items = &jsonschema.Schema{Type: "string"} 82 | case "intSlice", "int32Slice", "int64Slice", "uintSlice": 83 | flagSchema.Type = "array" 84 | flagSchema.Items = &jsonschema.Schema{Type: "integer"} 85 | case "float32Slice", "float64Slice": 86 | flagSchema.Type = "array" 87 | flagSchema.Items = &jsonschema.Schema{Type: "number"} 88 | case "boolSlice": 89 | flagSchema.Type = "array" 90 | flagSchema.Items = &jsonschema.Schema{Type: "boolean"} 91 | case "stringToString": 92 | flagSchema.Type = "object" 93 | flagSchema.AdditionalProperties = &jsonschema.Schema{Type: "string"} 94 | flagSchema.Description += " (format: key-value pairs)" 95 | case "stringToInt", "stringToInt64": 96 | flagSchema.Type = "object" 97 | flagSchema.AdditionalProperties = &jsonschema.Schema{Type: "integer"} 98 | flagSchema.Description += " (format: key-value pairs with integer values)" 99 | case "duration": 100 | // Duration is represented as a string in Go's duration format 101 | flagSchema.Type = "string" 102 | flagSchema.Description += " (format: Go duration string, e.g., '10s', '2h45m')" 103 | flagSchema.Pattern = `^-?([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$` 104 | case "ip": 105 | flagSchema.Type = "string" 106 | flagSchema.Description += " (format: IPv4 or IPv6 address)" 107 | flagSchema.Pattern = `^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.){3}(25[0-5]|(2[0-4]|1\d|[1-9]|)\d)$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4})$` 108 | case "ipMask": 109 | flagSchema.Type = "string" 110 | flagSchema.Description += " (format: IP mask, e.g., '255.255.255.0')" 111 | case "ipNet": 112 | flagSchema.Type = "string" 113 | flagSchema.Description += " (format: CIDR notation, e.g., '192.168.1.0/24')" 114 | flagSchema.Pattern = `^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.){3}(25[0-5]|(2[0-4]|1\d|[1-9]|)\d)/([0-9]|[1-2][0-9]|3[0-2])$` 115 | case "bytesHex": 116 | flagSchema.Type = "string" 117 | flagSchema.Description += " (format: hexadecimal string)" 118 | flagSchema.Pattern = `^[0-9a-fA-F]*$` 119 | case "bytesBase64": 120 | flagSchema.Type = "string" 121 | flagSchema.Description += " (format: base64 encoded string)" 122 | flagSchema.Pattern = `^[A-Za-z0-9+/]*={0,2}$` 123 | default: 124 | // Default to string for unknown types 125 | flagSchema.Type = "string" 126 | flagSchema.Description += fmt.Sprintf(" (type: %s)", t) 127 | slog.Debug("unknown flag type, defaulting to string", "flag", flag.Name, "type", t) 128 | } 129 | 130 | setDefaultFromFlag(flagSchema, flag) 131 | schema.Properties[flag.Name] = flagSchema 132 | } 133 | -------------------------------------------------------------------------------- /examples/kubectl/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/njayp/ophis/examples/kubectl 2 | 3 | go 1.24.6 4 | 5 | replace github.com/njayp/ophis => ../../ 6 | 7 | require ( 8 | github.com/njayp/ophis v1.0.9 9 | github.com/spf13/cobra v1.10.2 10 | k8s.io/client-go v0.34.2 11 | k8s.io/component-base v0.34.2 12 | k8s.io/kubectl v0.34.2 13 | ) 14 | 15 | require ( 16 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 17 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 18 | github.com/beorn7/perks v1.0.1 // indirect 19 | github.com/blang/semver/v4 v4.0.0 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/chai2010/gettext-go v1.0.3 // indirect 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 23 | github.com/distribution/reference v0.6.0 // indirect 24 | github.com/emicklei/go-restful/v3 v3.13.0 // indirect 25 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 26 | github.com/fatih/camelcase v1.0.0 // indirect 27 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 28 | github.com/go-errors/errors v1.5.1 // indirect 29 | github.com/go-logr/logr v1.4.3 // indirect 30 | github.com/go-openapi/jsonpointer v0.22.3 // indirect 31 | github.com/go-openapi/jsonreference v0.21.3 // indirect 32 | github.com/go-openapi/swag v0.25.4 // indirect 33 | github.com/go-openapi/swag/cmdutils v0.25.4 // indirect 34 | github.com/go-openapi/swag/conv v0.25.4 // indirect 35 | github.com/go-openapi/swag/fileutils v0.25.4 // indirect 36 | github.com/go-openapi/swag/jsonname v0.25.4 // indirect 37 | github.com/go-openapi/swag/jsonutils v0.25.4 // indirect 38 | github.com/go-openapi/swag/loading v0.25.4 // indirect 39 | github.com/go-openapi/swag/mangling v0.25.4 // indirect 40 | github.com/go-openapi/swag/netutils v0.25.4 // indirect 41 | github.com/go-openapi/swag/stringutils v0.25.4 // indirect 42 | github.com/go-openapi/swag/typeutils v0.25.4 // indirect 43 | github.com/go-openapi/swag/yamlutils v0.25.4 // indirect 44 | github.com/gogo/protobuf v1.3.2 // indirect 45 | github.com/google/btree v1.1.3 // indirect 46 | github.com/google/gnostic-models v0.7.1 // indirect 47 | github.com/google/go-cmp v0.7.0 // indirect 48 | github.com/google/jsonschema-go v0.3.0 // indirect 49 | github.com/google/uuid v1.6.0 // indirect 50 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 51 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 52 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 53 | github.com/jonboulle/clockwork v0.5.0 // indirect 54 | github.com/json-iterator/go v1.1.12 // indirect 55 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 56 | github.com/lithammer/dedent v1.1.0 // indirect 57 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 58 | github.com/moby/spdystream v0.5.0 // indirect 59 | github.com/moby/term v0.5.2 // indirect 60 | github.com/modelcontextprotocol/go-sdk v1.1.0 // indirect 61 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 62 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 63 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 64 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 65 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 66 | github.com/opencontainers/go-digest v1.0.0 // indirect 67 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 68 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 69 | github.com/prometheus/client_golang v1.23.2 // indirect 70 | github.com/prometheus/client_model v0.6.2 // indirect 71 | github.com/prometheus/common v0.67.4 // indirect 72 | github.com/prometheus/procfs v0.19.2 // indirect 73 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 74 | github.com/spf13/pflag v1.0.10 // indirect 75 | github.com/x448/float16 v0.8.4 // indirect 76 | github.com/xlab/treeprint v1.2.0 // indirect 77 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 78 | go.opentelemetry.io/otel v1.38.0 // indirect 79 | go.opentelemetry.io/otel/trace v1.38.0 // indirect 80 | go.yaml.in/yaml/v2 v2.4.3 // indirect 81 | go.yaml.in/yaml/v3 v3.0.4 // indirect 82 | golang.org/x/net v0.47.0 // indirect 83 | golang.org/x/oauth2 v0.33.0 // indirect 84 | golang.org/x/sync v0.18.0 // indirect 85 | golang.org/x/sys v0.38.0 // indirect 86 | golang.org/x/term v0.37.0 // indirect 87 | golang.org/x/text v0.31.0 // indirect 88 | golang.org/x/time v0.14.0 // indirect 89 | google.golang.org/protobuf v1.36.10 // indirect 90 | gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect 91 | gopkg.in/inf.v0 v0.9.1 // indirect 92 | k8s.io/api v0.34.2 // indirect 93 | k8s.io/apimachinery v0.34.2 // indirect 94 | k8s.io/cli-runtime v0.34.2 // indirect 95 | k8s.io/component-helpers v0.34.2 // indirect 96 | k8s.io/klog/v2 v2.130.1 // indirect 97 | k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect 98 | k8s.io/metrics v0.34.2 // indirect 99 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 100 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 101 | sigs.k8s.io/kustomize/api v0.21.0 // indirect 102 | sigs.k8s.io/kustomize/kustomize/v5 v5.8.0 // indirect 103 | sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect 104 | sigs.k8s.io/randfill v1.0.0 // indirect 105 | sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect 106 | sigs.k8s.io/yaml v1.6.0 // indirect 107 | ) 108 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/manager.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/njayp/ophis/internal/cfgmgr/manager/claude" 12 | "github.com/njayp/ophis/internal/cfgmgr/manager/cursor" 13 | "github.com/njayp/ophis/internal/cfgmgr/manager/vscode" 14 | ) 15 | 16 | // Config represents MCP server configuration that can be managed. 17 | type Config[S Server] interface { 18 | HasServer(name string) bool 19 | AddServer(name string, server S) 20 | RemoveServer(name string) 21 | Print() 22 | } 23 | 24 | // Server represents an individual MCP server entry. 25 | type Server interface { 26 | Print() 27 | } 28 | 29 | // Manager provides generic configuration management for MCP servers. 30 | // It handles loading, saving, and modifying MCP server configurations. 31 | // It is not thread-safe. 32 | type Manager[S Server, C Config[S]] struct { 33 | configPath string 34 | config C 35 | } 36 | 37 | // NewVSCodeManager creates a new Manager configured for VSCode MCP servers. 38 | // If workspace is true, uses workspace configuration (.vscode/mcp.json), 39 | // otherwise uses user-level configuration. 40 | func NewVSCodeManager(configPath string, workspace bool) (*Manager[vscode.Server, *vscode.Config], error) { 41 | if configPath == "" { 42 | configPath = vscode.ConfigPath(workspace) 43 | } 44 | 45 | m := &Manager[vscode.Server, *vscode.Config]{ 46 | config: &vscode.Config{}, 47 | configPath: configPath, 48 | } 49 | 50 | return m, m.loadConfig() 51 | } 52 | 53 | // NewCursorManager creates a new Manager configured for Cursor MCP servers. 54 | // If workspace is true, uses workspace configuration (.cursor/mcp.json), 55 | // otherwise uses user-level configuration. 56 | func NewCursorManager(configPath string, workspace bool) (*Manager[cursor.Server, *cursor.Config], error) { 57 | if configPath == "" { 58 | configPath = cursor.ConfigPath(workspace) 59 | } 60 | 61 | m := &Manager[cursor.Server, *cursor.Config]{ 62 | config: &cursor.Config{}, 63 | configPath: configPath, 64 | } 65 | 66 | return m, m.loadConfig() 67 | } 68 | 69 | // NewClaudeManager creates a new Manager configured for Claude Desktop MCP servers. 70 | func NewClaudeManager(configPath string) (*Manager[claude.Server, *claude.Config], error) { 71 | if configPath == "" { 72 | configPath = claude.ConfigPath() 73 | } 74 | 75 | m := &Manager[claude.Server, *claude.Config]{ 76 | config: &claude.Config{}, 77 | configPath: configPath, 78 | } 79 | 80 | return m, m.loadConfig() 81 | } 82 | 83 | // EnableServer adds or updates an MCP server in the configuration. 84 | func (m *Manager[S, C]) EnableServer(name string, server S) error { 85 | if m.config.HasServer(name) { 86 | fmt.Printf("⚠️ MCP server %q already exists and will be overwritten\n", name) 87 | } 88 | 89 | m.config.AddServer(name, server) 90 | err := m.saveConfig() 91 | if err != nil { 92 | return err 93 | } 94 | 95 | fmt.Printf("Successfully enabled MCP server: %q\n", name) 96 | server.Print() 97 | return nil 98 | } 99 | 100 | // DisableServer removes an MCP server from the configuration. 101 | func (m *Manager[S, C]) DisableServer(name string) error { 102 | if m.config.HasServer(name) { 103 | m.config.RemoveServer(name) 104 | return m.saveConfig() 105 | } 106 | 107 | fmt.Printf("⚠️ MCP server %q does not exist\n", name) 108 | return nil 109 | } 110 | 111 | // loadConfig unmarshals a JSON file into the provided interface. 112 | func (m *Manager[S, C]) loadConfig() error { 113 | fmt.Printf("Using config path %q\n", m.configPath) 114 | 115 | // Check if config file exists 116 | if _, err := os.Stat(m.configPath); os.IsNotExist(err) { 117 | // File doesn't exist - return nil to allow initialization 118 | return nil 119 | } 120 | 121 | data, err := os.ReadFile(m.configPath) 122 | if err != nil { 123 | return fmt.Errorf("failed to read configuration file at %q: %w", m.configPath, err) 124 | } 125 | 126 | if err := json.Unmarshal(data, m.config); err != nil { 127 | return fmt.Errorf("failed to parse configuration file at %q: invalid JSON format: %w", m.configPath, err) 128 | } 129 | 130 | return nil 131 | } 132 | 133 | // saveConfig marshals and saves configuration as formatted JSON. 134 | func (m *Manager[S, C]) saveConfig() error { 135 | // Ensure the directory exists 136 | if err := os.MkdirAll(filepath.Dir(m.configPath), 0o755); err != nil { 137 | return fmt.Errorf("failed to create directory for configuration file at %q: %w", filepath.Dir(m.configPath), err) 138 | } 139 | 140 | data, err := json.MarshalIndent(m.config, "", " ") 141 | if err != nil { 142 | return fmt.Errorf("failed to marshal configuration to JSON: %w", err) 143 | } 144 | 145 | // backup file 146 | err = m.backupConfig() 147 | if err != nil { 148 | return err 149 | } 150 | 151 | if err := os.WriteFile(m.configPath, data, 0o644); err != nil { 152 | return fmt.Errorf("failed to write configuration file at %q: %w", m.configPath, err) 153 | } 154 | 155 | return nil 156 | } 157 | 158 | // Print calls Print on the underlying config 159 | func (m *Manager[S, C]) Print() { 160 | m.config.Print() 161 | } 162 | 163 | func (m *Manager[S, C]) backupConfig() error { 164 | // Check if config file exists 165 | if _, err := os.Stat(m.configPath); os.IsNotExist(err) { 166 | // File doesn't exist - nothing to backup 167 | return nil 168 | } 169 | 170 | sourceFile, err := os.Open(m.configPath) 171 | if err != nil { 172 | return err 173 | } 174 | defer func() { 175 | _ = sourceFile.Close() 176 | }() 177 | 178 | ext := filepath.Ext(m.configPath) 179 | dest := strings.TrimSuffix(m.configPath, ext) + ".backup.json" 180 | fmt.Printf("Backing up config file at %q\n", dest) 181 | destFile, err := os.Create(dest) 182 | if err != nil { 183 | return err 184 | } 185 | defer func() { 186 | _ = destFile.Close() 187 | }() 188 | 189 | _, err = io.Copy(destFile, sourceFile) 190 | return err 191 | } 192 | -------------------------------------------------------------------------------- /internal/bridge/flags/default.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/google/jsonschema-go/jsonschema" 10 | "github.com/spf13/pflag" 11 | ) 12 | 13 | // setDefaultFromFlag sets the default value for a flag schema if it's not a zero value. 14 | func setDefaultFromFlag(flagSchema *jsonschema.Schema, flag *pflag.Flag) { 15 | defValue := flag.DefValue 16 | if defValue == "" { 17 | return 18 | } 19 | 20 | setDefault := func(val any) { 21 | if raw, err := json.Marshal(val); err == nil { 22 | flagSchema.Default = json.RawMessage(raw) 23 | } 24 | } 25 | 26 | // Parse the default value based on the schema type 27 | switch flagSchema.Type { 28 | case "boolean": 29 | if val, err := strconv.ParseBool(defValue); err == nil { 30 | setDefault(val) 31 | } 32 | case "integer": 33 | if val, err := strconv.ParseInt(defValue, 10, 64); err == nil { 34 | setDefault(val) 35 | } 36 | case "number": 37 | if val, err := strconv.ParseFloat(defValue, 64); err == nil { 38 | setDefault(val) 39 | } 40 | case "string": 41 | setDefault(defValue) 42 | case "array": 43 | if arr := parseArray(defValue, flagSchema.Items); arr != nil { 44 | setDefault(arr) 45 | } 46 | case "object": 47 | if obj := parseObject(defValue, flagSchema.AdditionalProperties); obj != nil { 48 | setDefault(obj) 49 | } 50 | } 51 | } 52 | 53 | // parseArray parses pflag's array representation ("[item1,item2]") into a typed slice. 54 | // Invalid array elements are skipped and logged as warnings. Returns nil for malformed input. 55 | // 56 | // Limitations: 57 | // - Does not support nested arrays 58 | // - Does not handle quoted strings containing commas 59 | // - Expects simple comma-separated values 60 | func parseArray(defValue string, schema *jsonschema.Schema) any { 61 | // pflag represents empty slices as "[]" 62 | if defValue == "[]" { 63 | return nil 64 | } 65 | 66 | // Verify array format 67 | if !strings.HasPrefix(defValue, "[") || !strings.HasSuffix(defValue, "]") { 68 | slog.Warn("malformed array default value: must start with '[' and end with ']'", "value", defValue) 69 | return nil 70 | } 71 | 72 | // Remove brackets 73 | inner := defValue[1 : len(defValue)-1] 74 | 75 | // Split by comma 76 | parts := strings.Split(inner, ",") 77 | 78 | // Parse based on item type 79 | switch schema.Type { 80 | case "integer": 81 | return parseIntArray(parts) 82 | case "number": 83 | return parseFloatArray(parts) 84 | case "boolean": 85 | return parseBoolArray(parts) 86 | case "string": 87 | return parseStringArray(parts) 88 | default: 89 | slog.Warn("unsupported array item type for default value", "type", schema.Type, "value", defValue) 90 | return nil 91 | } 92 | } 93 | 94 | // parseIntArray parses a slice of strings into a slice of int64. 95 | // Invalid elements are skipped and logged as warnings. 96 | func parseIntArray(parts []string) []int64 { 97 | result := make([]int64, 0, len(parts)) 98 | for i, p := range parts { 99 | trimmed := strings.TrimSpace(p) 100 | if val, err := strconv.ParseInt(trimmed, 10, 64); err == nil { 101 | result = append(result, val) 102 | } else { 103 | slog.Warn("skipping invalid integer in array default value", 104 | "index", i, 105 | "value", p, 106 | "error", err) 107 | } 108 | } 109 | return result 110 | } 111 | 112 | // parseFloatArray parses a slice of strings into a slice of float64. 113 | // Invalid elements are skipped and logged as warnings. 114 | func parseFloatArray(parts []string) []float64 { 115 | result := make([]float64, 0, len(parts)) 116 | for i, p := range parts { 117 | trimmed := strings.TrimSpace(p) 118 | if val, err := strconv.ParseFloat(trimmed, 64); err == nil { 119 | result = append(result, val) 120 | } else { 121 | slog.Warn("skipping invalid float in array default value", 122 | "index", i, 123 | "value", p, 124 | "error", err) 125 | } 126 | } 127 | return result 128 | } 129 | 130 | // parseBoolArray parses a slice of strings into a slice of bool. 131 | // Invalid elements are skipped and logged as warnings. 132 | func parseBoolArray(parts []string) []bool { 133 | result := make([]bool, 0, len(parts)) 134 | for i, p := range parts { 135 | trimmed := strings.TrimSpace(p) 136 | if val, err := strconv.ParseBool(trimmed); err == nil { 137 | result = append(result, val) 138 | } else { 139 | slog.Warn("skipping invalid boolean in array default value", 140 | "index", i, 141 | "value", p, 142 | "error", err) 143 | } 144 | } 145 | return result 146 | } 147 | 148 | // parseStringArray parses a slice of strings, trimming whitespace from each element. 149 | func parseStringArray(parts []string) []string { 150 | result := make([]string, 0, len(parts)) 151 | for _, p := range parts { 152 | result = append(result, strings.TrimSpace(p)) 153 | } 154 | return result 155 | } 156 | 157 | func parseObject(defValue string, schema *jsonschema.Schema) any { 158 | if defValue == "[]" { 159 | return nil 160 | } 161 | 162 | // Verify array format 163 | if !strings.HasPrefix(defValue, "[") || !strings.HasSuffix(defValue, "]") { 164 | slog.Warn("malformed array default value: must start with '[' and end with ']'", "value", defValue) 165 | return nil 166 | } 167 | 168 | // Remove brackets 169 | inner := defValue[1 : len(defValue)-1] 170 | 171 | // Split by comma 172 | parts := strings.Split(inner, ",") 173 | 174 | // Parse based on item type 175 | switch schema.Type { 176 | case "integer": 177 | return parseIntObj(parts) 178 | case "string": 179 | return parseStringObj(parts) 180 | default: 181 | slog.Warn("unsupported object item type for default value", "type", schema.Type, "value", defValue) 182 | return nil 183 | } 184 | } 185 | 186 | func parseIntObj(parts []string) map[string]int64 { 187 | result := make(map[string]int64) 188 | for i, p := range parts { 189 | trimmed := strings.TrimSpace(p) 190 | split := strings.SplitN(trimmed, "=", 2) 191 | if len(split) != 2 { 192 | slog.Warn("malformed flag default value object", "value", p) 193 | continue 194 | } 195 | 196 | key := strings.TrimSpace(split[0]) 197 | valStr := strings.TrimSpace(split[1]) 198 | if val, err := strconv.ParseInt(valStr, 10, 64); err == nil { 199 | result[key] = val 200 | } else { 201 | slog.Warn("skipping invalid integer obj in array default value", 202 | "index", i, 203 | "value", p, 204 | "error", err) 205 | } 206 | } 207 | 208 | if len(result) == 0 { 209 | return nil 210 | } 211 | return result 212 | } 213 | 214 | func parseStringObj(parts []string) map[string]string { 215 | result := make(map[string]string) 216 | for _, p := range parts { 217 | trimmed := strings.TrimSpace(p) 218 | split := strings.SplitN(trimmed, "=", 2) 219 | if len(split) != 2 { 220 | slog.Warn("malformed flag default value object", "value", p) 221 | continue 222 | } 223 | 224 | key := strings.TrimSpace(split[0]) 225 | val := strings.TrimSpace(split[1]) 226 | result[key] = val 227 | } 228 | 229 | if len(result) == 0 { 230 | return nil 231 | } 232 | return result 233 | } 234 | -------------------------------------------------------------------------------- /selector.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/google/jsonschema-go/jsonschema" 9 | "github.com/modelcontextprotocol/go-sdk/mcp" 10 | "github.com/njayp/ophis/internal/bridge/flags" 11 | "github.com/spf13/cobra" 12 | "github.com/spf13/pflag" 13 | ) 14 | 15 | // CmdSelector determines if a command should become an MCP tool. 16 | // Return true to include the command as a tool. 17 | // Note: Basic safety filters (hidden, deprecated, non-runnable) are always applied first. 18 | // Commands are tested against selectors in order; the first matching selector wins. 19 | type CmdSelector func(*cobra.Command) bool 20 | 21 | // FlagSelector determines if a flag should be included in an MCP tool. 22 | // Return true to include the flag. 23 | // Note: Hidden and deprecated flags are always excluded regardless of this selector. 24 | // This selector is only applied to commands that match the associated CmdSelector. 25 | type FlagSelector func(*pflag.Flag) bool 26 | 27 | // MiddlewareFunc is middleware hook that runs after each tool call 28 | // Common uses: error handling, response filtering, metrics collection. 29 | type MiddlewareFunc func(context.Context, *mcp.CallToolRequest, ToolInput, ExecuteFunc) (*mcp.CallToolResult, ToolOutput, error) 30 | 31 | // ExecuteFunc defines the function signature for executing a tool. 32 | type ExecuteFunc func(context.Context, *mcp.CallToolRequest, ToolInput) (*mcp.CallToolResult, ToolOutput, error) 33 | 34 | // Selector contains selectors for filtering commands and flags. 35 | // When multiple selectors are configured, they are evaluated in order. 36 | // The first selector whose CmdSelector matches a command is used, 37 | // and its FlagSelector determines which flags are included for that command. 38 | // 39 | // Basic safety filters are always applied automatically: 40 | // - Hidden/deprecated commands and flags are excluded 41 | // - Non-runnable commands are excluded 42 | // - Built-in commands (mcp, help, completion) are excluded 43 | // 44 | // This allows fine-grained control within safe boundaries, such as: 45 | // - Exposing different flags for different command groups 46 | // - Applying stricter flag filtering to dangerous commands 47 | // - Having a default catch-all selector with common flag exclusions 48 | type Selector struct { 49 | // CmdSelector determines if this selector applies to a command. 50 | // If nil, accepts all commands that pass basic safety filters. 51 | // Cannot be used to bypass safety filters (hidden, deprecated, non-runnable). 52 | CmdSelector CmdSelector 53 | 54 | // LocalFlagSelector determines which flags to include for commands matched by CmdSelector. 55 | // If nil, includes all flags that pass basic safety filters. 56 | // Cannot be used to bypass safety filters (hidden, deprecated flags). 57 | LocalFlagSelector FlagSelector 58 | 59 | // InheritedFlagSelector determines which persistent flags to include for commands matched by CmdSelector. 60 | // If nil, includes all flags that pass basic safety filters. 61 | // Cannot be used to bypass safety filters (hidden, deprecated flags). 62 | InheritedFlagSelector FlagSelector 63 | 64 | // Middleware is an optional middleware hook that wraps around tool execution. 65 | // Common uses: error handling, response filtering, metrics collection. 66 | // If nil, no middleware is applied. 67 | Middleware MiddlewareFunc 68 | } 69 | 70 | // enhanceFlagsSchema adds detailed flag information to the flags property. 71 | func (s Selector) enhanceFlagsSchema(schema *jsonschema.Schema, cmd *cobra.Command) { 72 | // Ensure properties map exists 73 | if schema.Properties == nil { 74 | schema.Properties = make(map[string]*jsonschema.Schema) 75 | } 76 | 77 | // basic filters 78 | filter := func(flag *pflag.Flag) bool { 79 | return flag.Hidden || flag.Deprecated != "" 80 | } 81 | 82 | // Process local flags 83 | cmd.LocalFlags().VisitAll(func(flag *pflag.Flag) { 84 | if filter(flag) { 85 | return 86 | } 87 | 88 | if s.LocalFlagSelector != nil && !s.LocalFlagSelector(flag) { 89 | return 90 | } 91 | 92 | flags.AddFlagToSchema(schema, flag) 93 | }) 94 | 95 | // Process inherited flags 96 | cmd.InheritedFlags().VisitAll(func(flag *pflag.Flag) { 97 | // Skip if already added as local flag 98 | if _, exists := schema.Properties[flag.Name]; exists { 99 | return 100 | } 101 | 102 | if filter(flag) { 103 | return 104 | } 105 | 106 | if s.InheritedFlagSelector != nil && !s.InheritedFlagSelector(flag) { 107 | return 108 | } 109 | 110 | flags.AddFlagToSchema(schema, flag) 111 | }) 112 | 113 | // Set AdditionalProperties to false 114 | // See https://github.com/google/jsonschema-go/issues/13 115 | schema.AdditionalProperties = &jsonschema.Schema{Not: &jsonschema.Schema{}} 116 | } 117 | 118 | // createToolFromCmd creates an MCP tool from a Cobra command. 119 | func (s Selector) createToolFromCmd(cmd *cobra.Command) *mcp.Tool { 120 | schema := inputSchema.Copy() 121 | s.enhanceFlagsSchema(schema.Properties["flags"], cmd) 122 | enhanceArgsSchema(schema.Properties["args"], cmd) 123 | 124 | // Create the tool 125 | return &mcp.Tool{ 126 | Name: toolName(cmd), 127 | Description: toolDescription(cmd), 128 | InputSchema: schema, 129 | OutputSchema: outputSchema.Copy(), 130 | } 131 | } 132 | 133 | // enhanceArgsSchema adds detailed argument information to the args property. 134 | func enhanceArgsSchema(schema *jsonschema.Schema, cmd *cobra.Command) { 135 | description := "Positional command line arguments" 136 | 137 | // remove "[flags]" from usage 138 | usage := strings.Replace(cmd.Use, " [flags]", "", 1) 139 | 140 | // Extract argument pattern from cmd.Use 141 | if usage != "" { 142 | if spaceIdx := strings.IndexByte(usage, ' '); spaceIdx != -1 { 143 | argsPattern := usage[spaceIdx+1:] 144 | if argsPattern != "" { 145 | description += fmt.Sprintf("\nUsage pattern: %s", argsPattern) 146 | } 147 | } 148 | } 149 | 150 | schema.Description = description 151 | } 152 | 153 | // toolName creates a tool name from the command path. 154 | func toolName(cmd *cobra.Command) string { 155 | path := cmd.CommandPath() 156 | return strings.ReplaceAll(path, " ", "_") 157 | } 158 | 159 | // toolDescription creates a comprehensive tool description. 160 | func toolDescription(cmd *cobra.Command) string { 161 | var parts []string 162 | 163 | // Use Long description if available, otherwise Short 164 | if cmd.Long != "" { 165 | parts = append(parts, cmd.Long) 166 | } else if cmd.Short != "" { 167 | parts = append(parts, cmd.Short) 168 | } else { 169 | parts = append(parts, fmt.Sprintf("Execute the %s command", cmd.Name())) 170 | } 171 | 172 | // Add examples if available 173 | if cmd.Example != "" { 174 | parts = append(parts, fmt.Sprintf("Examples:\n%s", cmd.Example)) 175 | } 176 | 177 | return strings.Join(parts, "\n") 178 | } 179 | 180 | func (s *Selector) execute(ctx context.Context, request *mcp.CallToolRequest, input ToolInput) (_ *mcp.CallToolResult, _ ToolOutput, err error) { 181 | defer func() { 182 | if r := recover(); r != nil { 183 | err = fmt.Errorf("panic: %v", r) 184 | } 185 | }() 186 | 187 | if s.Middleware != nil { 188 | return s.Middleware(ctx, request, input, execute) 189 | } 190 | 191 | return execute(ctx, request, input) 192 | } 193 | -------------------------------------------------------------------------------- /execute_test.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestBuildFlagArgs(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | flags map[string]any 13 | expected []string 14 | }{ 15 | { 16 | name: "Empty flags", 17 | flags: map[string]any{}, 18 | expected: []string{}, 19 | }, 20 | { 21 | name: "Boolean flags", 22 | flags: map[string]any{ 23 | "verbose": true, 24 | "quiet": false, 25 | "debug": true, 26 | }, 27 | expected: []string{"--verbose", "--debug"}, 28 | }, 29 | { 30 | name: "String flags", 31 | flags: map[string]any{ 32 | "output": "result.txt", 33 | "format": "json", 34 | }, 35 | expected: []string{"--output", "result.txt", "--format", "json"}, 36 | }, 37 | { 38 | name: "Integer flags", 39 | flags: map[string]any{ 40 | "count": 10, 41 | "timeout": 30, 42 | }, 43 | expected: []string{"--count", "10", "--timeout", "30"}, 44 | }, 45 | { 46 | name: "Float flags", 47 | flags: map[string]any{ 48 | "ratio": 0.75, 49 | "threshold": 1.5, 50 | }, 51 | expected: []string{"--ratio", "0.75", "--threshold", "1.5"}, 52 | }, 53 | { 54 | name: "Array flags", 55 | flags: map[string]any{ 56 | "include": []any{"*.go", "*.md"}, 57 | "exclude": []any{"vendor"}, 58 | }, 59 | expected: []string{"--include", "*.go", "--include", "*.md", "--exclude", "vendor"}, 60 | }, 61 | { 62 | name: "Mixed types", 63 | flags: map[string]any{ 64 | "verbose": true, 65 | "output": "result.txt", 66 | "count": 5, 67 | "tags": []any{"test", "debug"}, 68 | }, 69 | expected: []string{"--verbose", "--output", "result.txt", "--count", "5", "--tags", "test", "--tags", "debug"}, 70 | }, 71 | { 72 | name: "Map flags (stringToString)", 73 | flags: map[string]any{ 74 | "labels": map[string]any{ 75 | "env": "prod", 76 | "team": "backend", 77 | }, 78 | }, 79 | expected: []string{"--labels", "env=prod", "--labels", "team=backend"}, 80 | }, 81 | { 82 | name: "Empty map", 83 | flags: map[string]any{ 84 | "labels": map[string]any{}, 85 | }, 86 | expected: []string{}, 87 | }, 88 | { 89 | name: "StringToInt map flags", 90 | flags: map[string]any{ 91 | "ports": map[string]any{ 92 | "http": 8080, 93 | "https": 8443, 94 | }, 95 | }, 96 | expected: []string{"--ports", "http=8080", "--ports", "https=8443"}, 97 | }, 98 | { 99 | name: "StringToInt64 map flags", 100 | flags: map[string]any{ 101 | "sizes": map[string]any{ 102 | "small": int64(1024), 103 | "medium": int64(2048), 104 | "large": int64(4096), 105 | }, 106 | }, 107 | expected: []string{"--sizes", "small=1024", "--sizes", "medium=2048", "--sizes", "large=4096"}, 108 | }, 109 | { 110 | name: "Nil values", 111 | flags: map[string]any{ 112 | "flag1": nil, 113 | "flag2": "value", 114 | "flag3": nil, 115 | }, 116 | expected: []string{"--flag2", "value"}, 117 | }, 118 | { 119 | name: "Empty flag name", 120 | flags: map[string]any{ 121 | "": "value", 122 | "valid": "value", 123 | }, 124 | expected: []string{"--valid", "value"}, 125 | }, 126 | } 127 | 128 | for _, tt := range tests { 129 | t.Run(tt.name, func(t *testing.T) { 130 | result := buildFlagArgs(tt.flags) 131 | // Sort both slices for comparison since map iteration order is not guaranteed 132 | assert.ElementsMatch(t, tt.expected, result) 133 | }) 134 | } 135 | } 136 | 137 | func TestBuildCommandArgs(t *testing.T) { 138 | tests := []struct { 139 | name string 140 | commandName string 141 | input ToolInput 142 | expectedArgs []string 143 | }{ 144 | { 145 | name: "Simple command", 146 | commandName: "root_test", 147 | input: ToolInput{ 148 | Flags: map[string]any{}, 149 | Args: []string{}, 150 | }, 151 | expectedArgs: []string{"test"}, 152 | }, 153 | { 154 | name: "Nested command", 155 | commandName: "root_sub_command", 156 | input: ToolInput{ 157 | Flags: map[string]any{}, 158 | Args: []string{}, 159 | }, 160 | expectedArgs: []string{"sub", "command"}, 161 | }, 162 | { 163 | name: "Command with flags", 164 | commandName: "root_test", 165 | input: ToolInput{ 166 | Flags: map[string]any{ 167 | "verbose": true, 168 | "output": "result.txt", 169 | }, 170 | Args: []string{}, 171 | }, 172 | expectedArgs: []string{"test", "--verbose", "--output", "result.txt"}, 173 | }, 174 | { 175 | name: "Command with arguments", 176 | commandName: "root_test", 177 | input: ToolInput{ 178 | Flags: map[string]any{}, 179 | Args: []string{"file1.txt", "file2.txt"}, 180 | }, 181 | expectedArgs: []string{"test", "file1.txt", "file2.txt"}, 182 | }, 183 | { 184 | name: "Command with flags and arguments", 185 | commandName: "root_deploy", 186 | input: ToolInput{ 187 | Flags: map[string]any{ 188 | "namespace": "production", 189 | "replicas": 3, 190 | "wait": true, 191 | }, 192 | Args: []string{"my-app", "v1.2.3"}, 193 | }, 194 | expectedArgs: []string{"deploy", "--namespace", "production", "--replicas", "3", "--wait", "my-app", "v1.2.3"}, 195 | }, 196 | { 197 | name: "Complex nested command", 198 | commandName: "root_cluster_node_list", 199 | input: ToolInput{ 200 | Flags: map[string]any{ 201 | "output": "json", 202 | "label": []any{"env=prod", "team=backend"}, 203 | }, 204 | Args: []string{}, 205 | }, 206 | expectedArgs: []string{"cluster", "node", "list", "--output", "json", "--label", "env=prod", "--label", "team=backend"}, 207 | }, 208 | { 209 | name: "Command with map flags", 210 | commandName: "root_deploy", 211 | input: ToolInput{ 212 | Flags: map[string]any{ 213 | "labels": map[string]any{ 214 | "env": "production", 215 | "version": "v1.2.3", 216 | }, 217 | "wait": true, 218 | }, 219 | Args: []string{"my-app"}, 220 | }, 221 | expectedArgs: []string{"deploy", "--labels", "env=production", "--labels", "version=v1.2.3", "--wait", "my-app"}, 222 | }, 223 | { 224 | name: "Command with quoted arguments", 225 | commandName: "root_exec", 226 | input: ToolInput{ 227 | Flags: map[string]any{}, 228 | Args: []string{"argument with spaces", "another quoted arg", "normal"}, 229 | }, 230 | expectedArgs: []string{"exec", "argument with spaces", "another quoted arg", "normal"}, 231 | }, 232 | } 233 | 234 | for _, tt := range tests { 235 | t.Run(tt.name, func(t *testing.T) { 236 | result := buildCommandArgs(tt.commandName, tt.input) 237 | 238 | // Extract command parts for comparison 239 | commandParts := len(result) - len(tt.expectedArgs) 240 | if commandParts >= 0 { 241 | // Compare command parts 242 | assert.Equal(t, tt.expectedArgs[:commandParts], result[:commandParts], "Command parts mismatch") 243 | // Compare flags and args (order might vary for flags) 244 | assert.ElementsMatch(t, tt.expectedArgs[commandParts:], result[commandParts:], "Flags/args mismatch") 245 | } else { 246 | assert.Equal(t, tt.expectedArgs, result) 247 | } 248 | }) 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /examples/helm/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/njayp/ophis/examples/helm 2 | 3 | go 1.25.0 4 | 5 | replace github.com/njayp/ophis => ../../ 6 | 7 | require ( 8 | github.com/njayp/ophis v1.0.9 9 | github.com/spf13/cobra v1.10.2 10 | helm.sh/helm/v4 v4.0.1 11 | k8s.io/client-go v0.34.2 12 | ) 13 | 14 | require ( 15 | dario.cat/mergo v1.0.2 // indirect 16 | github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect 17 | github.com/BurntSushi/toml v1.5.0 // indirect 18 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 19 | github.com/Masterminds/goutils v1.1.1 // indirect 20 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 21 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 22 | github.com/Masterminds/squirrel v1.5.4 // indirect 23 | github.com/Masterminds/vcs v1.13.3 // indirect 24 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 25 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 26 | github.com/blang/semver/v4 v4.0.0 // indirect 27 | github.com/chai2010/gettext-go v1.0.3 // indirect 28 | github.com/clipperhouse/stringish v0.1.1 // indirect 29 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 30 | github.com/cloudflare/circl v1.6.1 // indirect 31 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 32 | github.com/cyphar/filepath-securejoin v0.6.1 // indirect 33 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 34 | github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect 35 | github.com/emicklei/go-restful/v3 v3.13.0 // indirect 36 | github.com/evanphx/json-patch/v5 v5.9.11 // indirect 37 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 38 | github.com/extism/go-sdk v1.7.1 // indirect 39 | github.com/fatih/color v1.18.0 // indirect 40 | github.com/fluxcd/cli-utils v0.36.0-flux.15 // indirect 41 | github.com/fxamacker/cbor/v2 v2.9.0 // indirect 42 | github.com/go-errors/errors v1.5.1 // indirect 43 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 44 | github.com/go-logr/logr v1.4.3 // indirect 45 | github.com/go-openapi/jsonpointer v0.22.3 // indirect 46 | github.com/go-openapi/jsonreference v0.21.3 // indirect 47 | github.com/go-openapi/swag v0.25.4 // indirect 48 | github.com/go-openapi/swag/cmdutils v0.25.4 // indirect 49 | github.com/go-openapi/swag/conv v0.25.4 // indirect 50 | github.com/go-openapi/swag/fileutils v0.25.4 // indirect 51 | github.com/go-openapi/swag/jsonname v0.25.4 // indirect 52 | github.com/go-openapi/swag/jsonutils v0.25.4 // indirect 53 | github.com/go-openapi/swag/loading v0.25.4 // indirect 54 | github.com/go-openapi/swag/mangling v0.25.4 // indirect 55 | github.com/go-openapi/swag/netutils v0.25.4 // indirect 56 | github.com/go-openapi/swag/stringutils v0.25.4 // indirect 57 | github.com/go-openapi/swag/typeutils v0.25.4 // indirect 58 | github.com/go-openapi/swag/yamlutils v0.25.4 // indirect 59 | github.com/gobwas/glob v0.2.3 // indirect 60 | github.com/gofrs/flock v0.13.0 // indirect 61 | github.com/gogo/protobuf v1.3.2 // indirect 62 | github.com/google/btree v1.1.3 // indirect 63 | github.com/google/gnostic-models v0.7.1 // indirect 64 | github.com/google/go-cmp v0.7.0 // indirect 65 | github.com/google/jsonschema-go v0.3.0 // indirect 66 | github.com/google/uuid v1.6.0 // indirect 67 | github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect 68 | github.com/gosuri/uitable v0.0.4 // indirect 69 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 70 | github.com/huandu/xstrings v1.5.0 // indirect 71 | github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect 72 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 73 | github.com/jmoiron/sqlx v1.4.0 // indirect 74 | github.com/json-iterator/go v1.1.12 // indirect 75 | github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect 76 | github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect 77 | github.com/lib/pq v1.10.9 // indirect 78 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 79 | github.com/mattn/go-colorable v0.1.14 // indirect 80 | github.com/mattn/go-isatty v0.0.20 // indirect 81 | github.com/mattn/go-runewidth v0.0.19 // indirect 82 | github.com/mitchellh/copystructure v1.2.0 // indirect 83 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 84 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 85 | github.com/moby/spdystream v0.5.0 // indirect 86 | github.com/moby/term v0.5.2 // indirect 87 | github.com/modelcontextprotocol/go-sdk v1.1.0 // indirect 88 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 89 | github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect 90 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 91 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 92 | github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect 93 | github.com/opencontainers/go-digest v1.0.0 // indirect 94 | github.com/opencontainers/image-spec v1.1.1 // indirect 95 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 96 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 97 | github.com/rubenv/sql-migrate v1.8.1 // indirect 98 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 99 | github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect 100 | github.com/shopspring/decimal v1.4.0 // indirect 101 | github.com/spf13/cast v1.10.0 // indirect 102 | github.com/spf13/pflag v1.0.10 // indirect 103 | github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect 104 | github.com/tetratelabs/wazero v1.10.1 // indirect 105 | github.com/x448/float16 v0.8.4 // indirect 106 | github.com/xlab/treeprint v1.2.0 // indirect 107 | github.com/yosida95/uritemplate/v3 v3.0.2 // indirect 108 | go.opentelemetry.io/proto/otlp v1.9.0 // indirect 109 | go.yaml.in/yaml/v2 v2.4.3 // indirect 110 | go.yaml.in/yaml/v3 v3.0.4 // indirect 111 | golang.org/x/crypto v0.45.0 // indirect 112 | golang.org/x/net v0.47.0 // indirect 113 | golang.org/x/oauth2 v0.33.0 // indirect 114 | golang.org/x/sync v0.18.0 // indirect 115 | golang.org/x/sys v0.38.0 // indirect 116 | golang.org/x/term v0.37.0 // indirect 117 | golang.org/x/text v0.31.0 // indirect 118 | golang.org/x/time v0.14.0 // indirect 119 | google.golang.org/protobuf v1.36.10 // indirect 120 | gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect 121 | gopkg.in/inf.v0 v0.9.1 // indirect 122 | k8s.io/api v0.34.2 // indirect 123 | k8s.io/apiextensions-apiserver v0.34.2 // indirect 124 | k8s.io/apimachinery v0.34.2 // indirect 125 | k8s.io/apiserver v0.34.2 // indirect 126 | k8s.io/cli-runtime v0.34.2 // indirect 127 | k8s.io/component-base v0.34.2 // indirect 128 | k8s.io/klog/v2 v2.130.1 // indirect 129 | k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e // indirect 130 | k8s.io/kubectl v0.34.2 // indirect 131 | k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect 132 | oras.land/oras-go/v2 v2.6.0 // indirect 133 | sigs.k8s.io/controller-runtime v0.22.4 // indirect 134 | sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect 135 | sigs.k8s.io/kustomize/api v0.21.0 // indirect 136 | sigs.k8s.io/kustomize/kyaml v0.21.0 // indirect 137 | sigs.k8s.io/randfill v1.0.0 // indirect 138 | sigs.k8s.io/structured-merge-diff/v6 v6.3.1 // indirect 139 | sigs.k8s.io/yaml v1.6.0 // indirect 140 | ) 141 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/cursor/config_test.go: -------------------------------------------------------------------------------- 1 | package cursor 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestConfig(t *testing.T) { 10 | t.Run("AddServer adds new server", func(t *testing.T) { 11 | config := &Config{} 12 | server := Server{ 13 | Type: "stdio", 14 | Command: "/usr/local/bin/myapp", 15 | Args: []string{"mcp", "start"}, 16 | } 17 | 18 | config.AddServer("test", server) 19 | 20 | assert.True(t, config.HasServer("test")) 21 | assert.Equal(t, 1, len(config.Servers)) 22 | }) 23 | 24 | t.Run("AddServer initializes map if nil", func(t *testing.T) { 25 | config := &Config{} 26 | assert.Nil(t, config.Servers) 27 | 28 | server := Server{ 29 | Type: "stdio", 30 | Command: "/usr/local/bin/myapp", 31 | } 32 | 33 | config.AddServer("test", server) 34 | assert.NotNil(t, config.Servers) 35 | assert.True(t, config.HasServer("test")) 36 | }) 37 | 38 | t.Run("AddServer updates existing server", func(t *testing.T) { 39 | config := &Config{} 40 | 41 | originalServer := Server{ 42 | Type: "stdio", 43 | Command: "/usr/local/bin/myapp", 44 | Args: []string{"start"}, 45 | } 46 | config.AddServer("test", originalServer) 47 | 48 | updatedServer := Server{ 49 | Type: "stdio", 50 | Command: "/usr/local/bin/myapp", 51 | Args: []string{"start", "--verbose"}, 52 | } 53 | config.AddServer("test", updatedServer) 54 | 55 | assert.Equal(t, 1, len(config.Servers)) 56 | assert.Equal(t, 2, len(config.Servers["test"].Args)) 57 | }) 58 | 59 | t.Run("HasServer returns false for non-existent server", func(t *testing.T) { 60 | config := &Config{ 61 | Servers: map[string]Server{ 62 | "server1": {Command: "/bin/app"}, 63 | }, 64 | } 65 | 66 | assert.False(t, config.HasServer("non-existent")) 67 | assert.True(t, config.HasServer("server1")) 68 | }) 69 | 70 | t.Run("RemoveServer removes existing server", func(t *testing.T) { 71 | config := &Config{ 72 | Servers: map[string]Server{ 73 | "server1": {Command: "/bin/app1"}, 74 | "server2": {Command: "/bin/app2"}, 75 | }, 76 | } 77 | 78 | config.RemoveServer("server1") 79 | 80 | assert.False(t, config.HasServer("server1")) 81 | assert.True(t, config.HasServer("server2")) 82 | assert.Equal(t, 1, len(config.Servers)) 83 | }) 84 | 85 | t.Run("RemoveServer handles non-existent server", func(t *testing.T) { 86 | config := &Config{ 87 | Servers: map[string]Server{ 88 | "server1": {Command: "/bin/app"}, 89 | }, 90 | } 91 | 92 | config.RemoveServer("non-existent") 93 | 94 | assert.Equal(t, 1, len(config.Servers)) 95 | assert.True(t, config.HasServer("server1")) 96 | }) 97 | 98 | t.Run("Config with inputs", func(t *testing.T) { 99 | config := &Config{ 100 | Inputs: []Input{ 101 | { 102 | Type: "promptString", 103 | ID: "api-key", 104 | Description: "Enter API key", 105 | Password: true, 106 | }, 107 | }, 108 | Servers: map[string]Server{ 109 | "server1": {Command: "/bin/app"}, 110 | }, 111 | } 112 | 113 | assert.Equal(t, 1, len(config.Inputs)) 114 | assert.Equal(t, "api-key", config.Inputs[0].ID) 115 | assert.True(t, config.Inputs[0].Password) 116 | }) 117 | 118 | t.Run("Multiple servers can be managed", func(t *testing.T) { 119 | config := &Config{} 120 | 121 | servers := []struct { 122 | name string 123 | server Server 124 | }{ 125 | {"kubectl", Server{Type: "stdio", Command: "/usr/local/bin/kubectl"}}, 126 | {"helm", Server{Type: "stdio", Command: "/usr/local/bin/helm"}}, 127 | {"argocd", Server{Type: "stdio", Command: "/usr/local/bin/argocd"}}, 128 | } 129 | 130 | for _, s := range servers { 131 | config.AddServer(s.name, s.server) 132 | } 133 | 134 | assert.Equal(t, 3, len(config.Servers)) 135 | for _, s := range servers { 136 | assert.True(t, config.HasServer(s.name)) 137 | } 138 | 139 | config.RemoveServer("helm") 140 | assert.Equal(t, 2, len(config.Servers)) 141 | assert.False(t, config.HasServer("helm")) 142 | }) 143 | } 144 | 145 | func TestMCPServer(t *testing.T) { 146 | t.Run("Server with stdio type", func(t *testing.T) { 147 | server := Server{ 148 | Type: "stdio", 149 | Command: "/usr/local/bin/myapp", 150 | } 151 | 152 | assert.Equal(t, "stdio", server.Type) 153 | assert.Equal(t, "/usr/local/bin/myapp", server.Command) 154 | assert.Empty(t, server.Args) 155 | assert.Empty(t, server.Env) 156 | assert.Empty(t, server.URL) 157 | }) 158 | 159 | t.Run("Server with args", func(t *testing.T) { 160 | server := Server{ 161 | Type: "stdio", 162 | Command: "/usr/local/bin/myapp", 163 | Args: []string{"mcp", "start", "--log-level", "debug"}, 164 | } 165 | 166 | assert.Equal(t, 4, len(server.Args)) 167 | assert.Equal(t, "mcp", server.Args[0]) 168 | assert.Equal(t, "debug", server.Args[3]) 169 | }) 170 | 171 | t.Run("Server with environment variables", func(t *testing.T) { 172 | server := Server{ 173 | Type: "stdio", 174 | Command: "/usr/local/bin/myapp", 175 | Env: map[string]string{ 176 | "DEBUG": "true", 177 | "LOG_FILE": "/var/log/app.log", 178 | }, 179 | } 180 | 181 | assert.Equal(t, 2, len(server.Env)) 182 | assert.Equal(t, "true", server.Env["DEBUG"]) 183 | assert.Equal(t, "/var/log/app.log", server.Env["LOG_FILE"]) 184 | }) 185 | 186 | t.Run("Server with HTTP type", func(t *testing.T) { 187 | server := Server{ 188 | Type: "http", 189 | URL: "https://api.example.com/mcp", 190 | Headers: map[string]string{ 191 | "Authorization": "Bearer token", 192 | "Content-Type": "application/json", 193 | }, 194 | } 195 | 196 | assert.Equal(t, "http", server.Type) 197 | assert.Equal(t, "https://api.example.com/mcp", server.URL) 198 | assert.Equal(t, 2, len(server.Headers)) 199 | assert.Equal(t, "Bearer token", server.Headers["Authorization"]) 200 | }) 201 | 202 | t.Run("Server with all fields", func(t *testing.T) { 203 | server := Server{ 204 | Type: "stdio", 205 | Command: "/usr/local/bin/myapp", 206 | Args: []string{"mcp", "start"}, 207 | Env: map[string]string{ 208 | "API_KEY": "secret", 209 | }, 210 | } 211 | 212 | assert.NotEmpty(t, server.Type) 213 | assert.NotEmpty(t, server.Command) 214 | assert.NotEmpty(t, server.Args) 215 | assert.NotEmpty(t, server.Env) 216 | }) 217 | } 218 | 219 | func TestInput(t *testing.T) { 220 | t.Run("Input with password flag", func(t *testing.T) { 221 | input := Input{ 222 | Type: "promptString", 223 | ID: "password", 224 | Description: "Enter password", 225 | Password: true, 226 | } 227 | 228 | assert.Equal(t, "promptString", input.Type) 229 | assert.Equal(t, "password", input.ID) 230 | assert.True(t, input.Password) 231 | }) 232 | 233 | t.Run("Input without password flag", func(t *testing.T) { 234 | input := Input{ 235 | Type: "promptString", 236 | ID: "username", 237 | Description: "Enter username", 238 | } 239 | 240 | assert.Equal(t, "promptString", input.Type) 241 | assert.Equal(t, "username", input.ID) 242 | assert.False(t, input.Password) 243 | }) 244 | 245 | t.Run("Multiple inputs", func(t *testing.T) { 246 | inputs := []Input{ 247 | { 248 | Type: "promptString", 249 | ID: "api-key", 250 | Description: "API Key", 251 | Password: true, 252 | }, 253 | { 254 | Type: "promptString", 255 | ID: "region", 256 | Description: "AWS Region", 257 | }, 258 | } 259 | 260 | assert.Equal(t, 2, len(inputs)) 261 | assert.True(t, inputs[0].Password) 262 | assert.False(t, inputs[1].Password) 263 | }) 264 | } 265 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/vscode/config_test.go: -------------------------------------------------------------------------------- 1 | package vscode 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestConfig(t *testing.T) { 10 | t.Run("AddServer adds new server", func(t *testing.T) { 11 | config := &Config{} 12 | server := Server{ 13 | Type: "stdio", 14 | Command: "/usr/local/bin/myapp", 15 | Args: []string{"mcp", "start"}, 16 | } 17 | 18 | config.AddServer("test", server) 19 | 20 | assert.True(t, config.HasServer("test")) 21 | assert.Equal(t, 1, len(config.Servers)) 22 | }) 23 | 24 | t.Run("AddServer initializes map if nil", func(t *testing.T) { 25 | config := &Config{} 26 | assert.Nil(t, config.Servers) 27 | 28 | server := Server{ 29 | Type: "stdio", 30 | Command: "/usr/local/bin/myapp", 31 | } 32 | 33 | config.AddServer("test", server) 34 | assert.NotNil(t, config.Servers) 35 | assert.True(t, config.HasServer("test")) 36 | }) 37 | 38 | t.Run("AddServer updates existing server", func(t *testing.T) { 39 | config := &Config{} 40 | 41 | originalServer := Server{ 42 | Type: "stdio", 43 | Command: "/usr/local/bin/myapp", 44 | Args: []string{"start"}, 45 | } 46 | config.AddServer("test", originalServer) 47 | 48 | updatedServer := Server{ 49 | Type: "stdio", 50 | Command: "/usr/local/bin/myapp", 51 | Args: []string{"start", "--verbose"}, 52 | } 53 | config.AddServer("test", updatedServer) 54 | 55 | assert.Equal(t, 1, len(config.Servers)) 56 | assert.Equal(t, 2, len(config.Servers["test"].Args)) 57 | }) 58 | 59 | t.Run("HasServer returns false for non-existent server", func(t *testing.T) { 60 | config := &Config{ 61 | Servers: map[string]Server{ 62 | "server1": {Command: "/bin/app"}, 63 | }, 64 | } 65 | 66 | assert.False(t, config.HasServer("non-existent")) 67 | assert.True(t, config.HasServer("server1")) 68 | }) 69 | 70 | t.Run("RemoveServer removes existing server", func(t *testing.T) { 71 | config := &Config{ 72 | Servers: map[string]Server{ 73 | "server1": {Command: "/bin/app1"}, 74 | "server2": {Command: "/bin/app2"}, 75 | }, 76 | } 77 | 78 | config.RemoveServer("server1") 79 | 80 | assert.False(t, config.HasServer("server1")) 81 | assert.True(t, config.HasServer("server2")) 82 | assert.Equal(t, 1, len(config.Servers)) 83 | }) 84 | 85 | t.Run("RemoveServer handles non-existent server", func(t *testing.T) { 86 | config := &Config{ 87 | Servers: map[string]Server{ 88 | "server1": {Command: "/bin/app"}, 89 | }, 90 | } 91 | 92 | config.RemoveServer("non-existent") 93 | 94 | assert.Equal(t, 1, len(config.Servers)) 95 | assert.True(t, config.HasServer("server1")) 96 | }) 97 | 98 | t.Run("Config with inputs", func(t *testing.T) { 99 | config := &Config{ 100 | Inputs: []Input{ 101 | { 102 | Type: "promptString", 103 | ID: "api-key", 104 | Description: "Enter API key", 105 | Password: true, 106 | }, 107 | }, 108 | Servers: map[string]Server{ 109 | "server1": {Command: "/bin/app"}, 110 | }, 111 | } 112 | 113 | assert.Equal(t, 1, len(config.Inputs)) 114 | assert.Equal(t, "api-key", config.Inputs[0].ID) 115 | assert.True(t, config.Inputs[0].Password) 116 | }) 117 | 118 | t.Run("Multiple servers can be managed", func(t *testing.T) { 119 | config := &Config{} 120 | 121 | servers := []struct { 122 | name string 123 | server Server 124 | }{ 125 | {"kubectl", Server{Type: "stdio", Command: "/usr/local/bin/kubectl"}}, 126 | {"helm", Server{Type: "stdio", Command: "/usr/local/bin/helm"}}, 127 | {"argocd", Server{Type: "stdio", Command: "/usr/local/bin/argocd"}}, 128 | } 129 | 130 | for _, s := range servers { 131 | config.AddServer(s.name, s.server) 132 | } 133 | 134 | assert.Equal(t, 3, len(config.Servers)) 135 | for _, s := range servers { 136 | assert.True(t, config.HasServer(s.name)) 137 | } 138 | 139 | config.RemoveServer("helm") 140 | assert.Equal(t, 2, len(config.Servers)) 141 | assert.False(t, config.HasServer("helm")) 142 | }) 143 | } 144 | 145 | func TestMCPServer(t *testing.T) { 146 | t.Run("Server with stdio type", func(t *testing.T) { 147 | server := Server{ 148 | Type: "stdio", 149 | Command: "/usr/local/bin/myapp", 150 | } 151 | 152 | assert.Equal(t, "stdio", server.Type) 153 | assert.Equal(t, "/usr/local/bin/myapp", server.Command) 154 | assert.Empty(t, server.Args) 155 | assert.Empty(t, server.Env) 156 | assert.Empty(t, server.URL) 157 | }) 158 | 159 | t.Run("Server with args", func(t *testing.T) { 160 | server := Server{ 161 | Type: "stdio", 162 | Command: "/usr/local/bin/myapp", 163 | Args: []string{"mcp", "start", "--log-level", "debug"}, 164 | } 165 | 166 | assert.Equal(t, 4, len(server.Args)) 167 | assert.Equal(t, "mcp", server.Args[0]) 168 | assert.Equal(t, "debug", server.Args[3]) 169 | }) 170 | 171 | t.Run("Server with environment variables", func(t *testing.T) { 172 | server := Server{ 173 | Type: "stdio", 174 | Command: "/usr/local/bin/myapp", 175 | Env: map[string]string{ 176 | "DEBUG": "true", 177 | "LOG_FILE": "/var/log/app.log", 178 | }, 179 | } 180 | 181 | assert.Equal(t, 2, len(server.Env)) 182 | assert.Equal(t, "true", server.Env["DEBUG"]) 183 | assert.Equal(t, "/var/log/app.log", server.Env["LOG_FILE"]) 184 | }) 185 | 186 | t.Run("Server with HTTP type", func(t *testing.T) { 187 | server := Server{ 188 | Type: "http", 189 | URL: "https://api.example.com/mcp", 190 | Headers: map[string]string{ 191 | "Authorization": "Bearer token", 192 | "Content-Type": "application/json", 193 | }, 194 | } 195 | 196 | assert.Equal(t, "http", server.Type) 197 | assert.Equal(t, "https://api.example.com/mcp", server.URL) 198 | assert.Equal(t, 2, len(server.Headers)) 199 | assert.Equal(t, "Bearer token", server.Headers["Authorization"]) 200 | }) 201 | 202 | t.Run("Server with all fields", func(t *testing.T) { 203 | server := Server{ 204 | Type: "stdio", 205 | Command: "/usr/local/bin/myapp", 206 | Args: []string{"mcp", "start"}, 207 | Env: map[string]string{ 208 | "API_KEY": "secret", 209 | }, 210 | } 211 | 212 | assert.NotEmpty(t, server.Type) 213 | assert.NotEmpty(t, server.Command) 214 | assert.NotEmpty(t, server.Args) 215 | assert.NotEmpty(t, server.Env) 216 | }) 217 | } 218 | 219 | func TestInput(t *testing.T) { 220 | t.Run("Input with password flag", func(t *testing.T) { 221 | input := Input{ 222 | Type: "promptString", 223 | ID: "password", 224 | Description: "Enter password", 225 | Password: true, 226 | } 227 | 228 | assert.Equal(t, "promptString", input.Type) 229 | assert.Equal(t, "password", input.ID) 230 | assert.True(t, input.Password) 231 | }) 232 | 233 | t.Run("Input without password flag", func(t *testing.T) { 234 | input := Input{ 235 | Type: "promptString", 236 | ID: "username", 237 | Description: "Enter username", 238 | } 239 | 240 | assert.Equal(t, "promptString", input.Type) 241 | assert.Equal(t, "username", input.ID) 242 | assert.False(t, input.Password) 243 | }) 244 | 245 | t.Run("Multiple inputs", func(t *testing.T) { 246 | inputs := []Input{ 247 | { 248 | Type: "promptString", 249 | ID: "api-key", 250 | Description: "API Key", 251 | Password: true, 252 | }, 253 | { 254 | Type: "promptString", 255 | ID: "region", 256 | Description: "AWS Region", 257 | }, 258 | } 259 | 260 | assert.Equal(t, 2, len(inputs)) 261 | assert.True(t, inputs[0].Password) 262 | assert.False(t, inputs[1].Password) 263 | }) 264 | } 265 | -------------------------------------------------------------------------------- /internal/cfgmgr/manager/manager_test.go: -------------------------------------------------------------------------------- 1 | package manager 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/njayp/ophis/internal/cfgmgr/manager/claude" 10 | "github.com/njayp/ophis/internal/cfgmgr/manager/vscode" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestClaudeManager(t *testing.T) { 16 | // Create a temporary directory for test configs 17 | tmpDir := t.TempDir() 18 | configPath := filepath.Join(tmpDir, "claude_config.json") 19 | 20 | t.Run("NewClaudeManager creates empty config", func(t *testing.T) { 21 | m, err := NewClaudeManager(configPath) 22 | require.NoError(t, err) 23 | assert.NotNil(t, m) 24 | assert.Equal(t, configPath, m.configPath) 25 | }) 26 | 27 | t.Run("EnableServer adds new server", func(t *testing.T) { 28 | m, err := NewClaudeManager(configPath) 29 | require.NoError(t, err) 30 | 31 | server := claude.Server{ 32 | Command: "/usr/local/bin/myapp", 33 | Args: []string{"mcp", "start"}, 34 | } 35 | 36 | err = m.EnableServer("test-server", server) 37 | require.NoError(t, err) 38 | 39 | // Verify the server was added 40 | assert.True(t, m.config.HasServer("test-server")) 41 | 42 | // Verify the config was saved 43 | data, err := os.ReadFile(configPath) 44 | require.NoError(t, err) 45 | 46 | var savedConfig claude.Config 47 | err = json.Unmarshal(data, &savedConfig) 48 | require.NoError(t, err) 49 | assert.True(t, savedConfig.HasServer("test-server")) 50 | }) 51 | 52 | t.Run("EnableServer updates existing server", func(t *testing.T) { 53 | m, err := NewClaudeManager(configPath) 54 | require.NoError(t, err) 55 | 56 | originalServer := claude.Server{ 57 | Command: "/usr/local/bin/myapp", 58 | Args: []string{"mcp", "start"}, 59 | } 60 | 61 | err = m.EnableServer("test-server", originalServer) 62 | require.NoError(t, err) 63 | 64 | updatedServer := claude.Server{ 65 | Command: "/usr/local/bin/myapp", 66 | Args: []string{"mcp", "start", "--log-level", "debug"}, 67 | } 68 | 69 | err = m.EnableServer("test-server", updatedServer) 70 | require.NoError(t, err) 71 | 72 | // Reload and verify the update 73 | m2, err := NewClaudeManager(configPath) 74 | require.NoError(t, err) 75 | assert.True(t, m2.config.HasServer("test-server")) 76 | }) 77 | 78 | t.Run("DisableServer removes existing server", func(t *testing.T) { 79 | m, err := NewClaudeManager(configPath) 80 | require.NoError(t, err) 81 | 82 | server := claude.Server{ 83 | Command: "/usr/local/bin/myapp", 84 | Args: []string{"mcp", "start"}, 85 | } 86 | 87 | err = m.EnableServer("test-server", server) 88 | require.NoError(t, err) 89 | 90 | err = m.DisableServer("test-server") 91 | require.NoError(t, err) 92 | 93 | // Verify the server was removed 94 | assert.False(t, m.config.HasServer("test-server")) 95 | 96 | // Reload and verify persistence 97 | m2, err := NewClaudeManager(configPath) 98 | require.NoError(t, err) 99 | assert.False(t, m2.config.HasServer("test-server")) 100 | }) 101 | 102 | t.Run("DisableServer handles non-existent server", func(t *testing.T) { 103 | m, err := NewClaudeManager(configPath) 104 | require.NoError(t, err) 105 | 106 | err = m.DisableServer("non-existent") 107 | require.NoError(t, err) 108 | }) 109 | 110 | t.Run("loadConfig handles missing file", func(t *testing.T) { 111 | nonExistentPath := filepath.Join(tmpDir, "non-existent.json") 112 | m, err := NewClaudeManager(nonExistentPath) 113 | require.NoError(t, err) 114 | assert.NotNil(t, m) 115 | }) 116 | 117 | t.Run("loadConfig handles invalid JSON", func(t *testing.T) { 118 | invalidPath := filepath.Join(tmpDir, "invalid.json") 119 | err := os.WriteFile(invalidPath, []byte("not valid json"), 0o644) 120 | require.NoError(t, err) 121 | 122 | _, err = NewClaudeManager(invalidPath) 123 | require.Error(t, err) 124 | assert.Contains(t, err.Error(), "invalid JSON format") 125 | }) 126 | 127 | t.Run("backupConfig creates backup", func(t *testing.T) { 128 | backupTestPath := filepath.Join(tmpDir, "backup_test.json") 129 | m, err := NewClaudeManager(backupTestPath) 130 | require.NoError(t, err) 131 | 132 | server := claude.Server{ 133 | Command: "/usr/local/bin/myapp", 134 | Args: []string{"mcp", "start"}, 135 | } 136 | 137 | err = m.EnableServer("backup-test", server) 138 | require.NoError(t, err) 139 | 140 | // Make a change to trigger backup 141 | err = m.EnableServer("backup-test-2", server) 142 | require.NoError(t, err) 143 | 144 | // Verify backup exists 145 | backupPath := filepath.Join(tmpDir, "backup_test.backup.json") 146 | _, err = os.Stat(backupPath) 147 | require.NoError(t, err) 148 | }) 149 | } 150 | 151 | func TestVSCodeManager(t *testing.T) { 152 | // Create a temporary directory for test configs 153 | tmpDir := t.TempDir() 154 | configPath := filepath.Join(tmpDir, "vscode_config.json") 155 | 156 | t.Run("NewVSCodeManager creates empty config", func(t *testing.T) { 157 | m, err := NewVSCodeManager(configPath, false) 158 | require.NoError(t, err) 159 | assert.NotNil(t, m) 160 | assert.Equal(t, configPath, m.configPath) 161 | }) 162 | 163 | t.Run("EnableServer adds new server", func(t *testing.T) { 164 | m, err := NewVSCodeManager(configPath, false) 165 | require.NoError(t, err) 166 | 167 | server := vscode.Server{ 168 | Type: "stdio", 169 | Command: "/usr/local/bin/myapp", 170 | Args: []string{"mcp", "start"}, 171 | } 172 | 173 | err = m.EnableServer("test-server", server) 174 | require.NoError(t, err) 175 | 176 | // Verify the server was added 177 | assert.True(t, m.config.HasServer("test-server")) 178 | 179 | // Verify the config was saved 180 | data, err := os.ReadFile(configPath) 181 | require.NoError(t, err) 182 | 183 | var savedConfig vscode.Config 184 | err = json.Unmarshal(data, &savedConfig) 185 | require.NoError(t, err) 186 | assert.True(t, savedConfig.HasServer("test-server")) 187 | }) 188 | 189 | t.Run("EnableServer with environment variables", func(t *testing.T) { 190 | m, err := NewVSCodeManager(configPath, false) 191 | require.NoError(t, err) 192 | 193 | server := vscode.Server{ 194 | Type: "stdio", 195 | Command: "/usr/local/bin/myapp", 196 | Args: []string{"mcp", "start"}, 197 | Env: map[string]string{ 198 | "DEBUG": "true", 199 | "PORT": "8080", 200 | }, 201 | } 202 | 203 | err = m.EnableServer("env-test", server) 204 | require.NoError(t, err) 205 | 206 | // Reload and verify 207 | m2, err := NewVSCodeManager(configPath, false) 208 | require.NoError(t, err) 209 | assert.True(t, m2.config.HasServer("env-test")) 210 | }) 211 | 212 | t.Run("DisableServer removes existing server", func(t *testing.T) { 213 | m, err := NewVSCodeManager(configPath, false) 214 | require.NoError(t, err) 215 | 216 | server := vscode.Server{ 217 | Type: "stdio", 218 | Command: "/usr/local/bin/myapp", 219 | Args: []string{"mcp", "start"}, 220 | } 221 | 222 | err = m.EnableServer("test-server", server) 223 | require.NoError(t, err) 224 | 225 | err = m.DisableServer("test-server") 226 | require.NoError(t, err) 227 | 228 | // Verify the server was removed 229 | assert.False(t, m.config.HasServer("test-server")) 230 | }) 231 | 232 | t.Run("Multiple servers can coexist", func(t *testing.T) { 233 | m, err := NewVSCodeManager(configPath, false) 234 | require.NoError(t, err) 235 | 236 | server1 := vscode.Server{ 237 | Type: "stdio", 238 | Command: "/usr/local/bin/app1", 239 | Args: []string{"mcp", "start"}, 240 | } 241 | 242 | server2 := vscode.Server{ 243 | Type: "stdio", 244 | Command: "/usr/local/bin/app2", 245 | Args: []string{"mcp", "start"}, 246 | } 247 | 248 | err = m.EnableServer("server1", server1) 249 | require.NoError(t, err) 250 | 251 | err = m.EnableServer("server2", server2) 252 | require.NoError(t, err) 253 | 254 | assert.True(t, m.config.HasServer("server1")) 255 | assert.True(t, m.config.HasServer("server2")) 256 | 257 | // Disable one and verify the other remains 258 | err = m.DisableServer("server1") 259 | require.NoError(t, err) 260 | 261 | assert.False(t, m.config.HasServer("server1")) 262 | assert.True(t, m.config.HasServer("server2")) 263 | }) 264 | } 265 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /selector_test.go: -------------------------------------------------------------------------------- 1 | package ophis 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | 7 | "github.com/google/jsonschema-go/jsonschema" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | // buildCommandTree creates a command tree from a list of command names. 15 | // The first command becomes the root, and subsequent commands are nested. 16 | func buildCommandTree(names ...string) *cobra.Command { 17 | if len(names) == 0 { 18 | return nil 19 | } 20 | 21 | root := &cobra.Command{Use: names[0]} 22 | parent := root 23 | 24 | for _, name := range names[1:] { 25 | child := &cobra.Command{ 26 | Use: name, 27 | Run: func(_ *cobra.Command, _ []string) {}, 28 | } 29 | 30 | parent.AddCommand(child) 31 | parent = child 32 | } 33 | 34 | return parent 35 | } 36 | 37 | type SomeJSONObject struct { 38 | Foo string 39 | Bar int 40 | FooBar struct { 41 | Baz string 42 | } 43 | } 44 | 45 | type SomeJSONArray []SomeJSONObject 46 | 47 | func TestCreateToolFromCmd(t *testing.T) { 48 | // Create a simple test command 49 | cmd := &cobra.Command{ 50 | Use: "test [file]", 51 | Short: "Test command", 52 | Long: "This is a test command for testing the ophis package", 53 | Example: "test file.txt --output result.txt", 54 | } 55 | 56 | // Add some flags 57 | cmd.Flags().String("output", "", "Output file") 58 | cmd.Flags().Bool("verbose", false, "Verbose output") 59 | cmd.Flags().IntSlice("include", []int{}, "Include patterns") 60 | cmd.Flags().StringSlice("greeting", []string{"hello", "world"}, "Include patterns") 61 | cmd.Flags().Int("count", 10, "Number of items") 62 | cmd.Flags().StringToString("labels", map[string]string{"hello": "world", "go": "lang"}, "Key-value labels") 63 | cmd.Flags().StringToInt("ports", map[string]int{"life": 42, "power": 9001}, "Port mappings") 64 | 65 | // generate schema for a test object 66 | aJSONObjSchema, err := jsonschema.For[SomeJSONObject](nil) 67 | require.NoError(t, err) 68 | bytes, err := aJSONObjSchema.MarshalJSON() 69 | require.NoError(t, err) 70 | 71 | // now create flag that has a json schema that represents a json object 72 | cmd.Flags().String("a_json_obj", "", "Some JSON Object") 73 | jsonobj := cmd.Flags().Lookup("a_json_obj") 74 | jsonobj.Annotations = make(map[string][]string) 75 | jsonobj.Annotations["jsonschema"] = []string{string(bytes)} 76 | 77 | // generate schema for a test array 78 | aJSONArraySchema, err := jsonschema.For[SomeJSONArray](nil) 79 | require.NoError(t, err) 80 | bytes, err = aJSONArraySchema.MarshalJSON() 81 | require.NoError(t, err) 82 | 83 | // now create flag that has a json schema that represents a json array 84 | // note that we can supply a default for the flag here but it's not mapped to the schema default 85 | cmd.Flags().String("a_json_array", "[]", "Some JSON Array") 86 | jsonarray := cmd.Flags().Lookup("a_json_array") 87 | jsonarray.Annotations = make(map[string][]string) 88 | jsonarray.Annotations["jsonschema"] = []string{string(bytes)} 89 | 90 | // Add a hidden flag 91 | cmd.Flags().String("hidden", "secret", "Hidden flag") 92 | err = cmd.Flags().MarkHidden("hidden") 93 | require.NoError(t, err) 94 | 95 | // Add a deprecated flag 96 | cmd.Flags().String("old", "", "Old flag") 97 | err = cmd.Flags().MarkDeprecated("old", "Use --new instead") 98 | require.NoError(t, err) 99 | 100 | // Mark one flag as required 101 | err = cmd.MarkFlagRequired("count") 102 | require.NoError(t, err) 103 | 104 | parent := &cobra.Command{ 105 | Use: "parent", 106 | Short: "Parent command", 107 | } 108 | 109 | // add persistent flag to parent 110 | parent.PersistentFlags().String("config", "", "Config file") 111 | parent.AddCommand(cmd) 112 | 113 | t.Run("Default Selector", func(t *testing.T) { 114 | // Create tool from command with a selector that accepts all flags 115 | tool := Selector{}.createToolFromCmd(cmd) 116 | 117 | // Verify tool properties 118 | assert.Equal(t, "parent_test", tool.Name) 119 | assert.Contains(t, tool.Description, "This is a test command") 120 | assert.Contains(t, tool.Description, "test file.txt --output result.txt") 121 | assert.NotNil(t, tool.InputSchema) 122 | 123 | // Verify schema structure 124 | inputSchema := tool.InputSchema.(*jsonschema.Schema) 125 | assert.Equal(t, "object", inputSchema.Type) 126 | require.NotNil(t, inputSchema.Properties) 127 | assert.Contains(t, inputSchema.Properties, "flags") 128 | assert.Contains(t, inputSchema.Properties, "args") 129 | 130 | // Verify flags schema 131 | flagsSchema := inputSchema.Properties["flags"] 132 | require.NotNil(t, flagsSchema.Properties) 133 | assert.Contains(t, flagsSchema.Properties, "output") 134 | assert.Contains(t, flagsSchema.Properties, "verbose") 135 | assert.Contains(t, flagsSchema.Properties, "include") 136 | assert.Contains(t, flagsSchema.Properties, "count") 137 | assert.Contains(t, flagsSchema.Properties, "greeting") 138 | assert.Contains(t, flagsSchema.Properties, "labels") 139 | assert.Contains(t, flagsSchema.Properties, "ports") 140 | assert.Contains(t, flagsSchema.Properties, "a_json_obj") 141 | assert.Contains(t, flagsSchema.Properties, "a_json_array") 142 | 143 | // Verify excluded flags 144 | assert.NotContains(t, flagsSchema.Properties, "hidden", "Should not include hidden flag") 145 | assert.NotContains(t, flagsSchema.Properties, "old", "Should not include deprecated flag") 146 | 147 | // Verify flag types 148 | assert.Equal(t, "string", flagsSchema.Properties["output"].Type) 149 | assert.Equal(t, "boolean", flagsSchema.Properties["verbose"].Type) 150 | assert.Equal(t, "array", flagsSchema.Properties["include"].Type) 151 | assert.Equal(t, "integer", flagsSchema.Properties["count"].Type) 152 | assert.Equal(t, "array", flagsSchema.Properties["greeting"].Type) 153 | assert.Equal(t, "object", flagsSchema.Properties["labels"].Type) 154 | assert.Equal(t, "object", flagsSchema.Properties["ports"].Type) 155 | assert.Equal(t, "object", flagsSchema.Properties["a_json_obj"].Type) 156 | assert.Equal(t, "array", flagsSchema.Properties["a_json_array"].Type) 157 | 158 | // Verify required flags 159 | require.Len(t, flagsSchema.Required, 1, "Should have 1 required flag") 160 | assert.Contains(t, flagsSchema.Required, "count", "count flag should be marked as required") 161 | 162 | // Verify default values 163 | assert.NotNil(t, flagsSchema.Properties["verbose"].Default) 164 | assert.JSONEq(t, "false", string(flagsSchema.Properties["verbose"].Default)) 165 | assert.NotNil(t, flagsSchema.Properties["count"].Default) 166 | assert.JSONEq(t, "10", string(flagsSchema.Properties["count"].Default)) 167 | assert.NotNil(t, flagsSchema.Properties["greeting"].Default) 168 | assert.JSONEq(t, `["hello","world"]`, string(flagsSchema.Properties["greeting"].Default)) 169 | assert.JSONEq(t, `{"life":42, "power":9001}`, string(flagsSchema.Properties["ports"].Default)) 170 | assert.JSONEq(t, `{"hello":"world", "go":"lang"}`, string(flagsSchema.Properties["labels"].Default)) 171 | // Empty string and empty array should not have defaults set 172 | assert.Nil(t, flagsSchema.Properties["output"].Default) 173 | assert.Nil(t, flagsSchema.Properties["include"].Default) 174 | 175 | // json schema defaults are not populated 176 | assert.Nil(t, flagsSchema.Properties["a_json_obj"].Default) 177 | assert.Nil(t, flagsSchema.Properties["a_json_array"].Default) 178 | 179 | // verify json obj schemas 180 | parsedJSONObjSchema := flagsSchema.Properties["a_json_obj"] 181 | assert.Equal(t, aJSONObjSchema, parsedJSONObjSchema) 182 | 183 | // Verify array items schema 184 | includeSchema := flagsSchema.Properties["include"] 185 | assert.NotNil(t, includeSchema.Items) 186 | assert.Equal(t, "integer", includeSchema.Items.Type) 187 | greetingSchema := flagsSchema.Properties["greeting"] 188 | assert.NotNil(t, greetingSchema.Items) 189 | assert.Equal(t, "string", greetingSchema.Items.Type) 190 | 191 | // Verify stringToString object schema 192 | labelsSchema := flagsSchema.Properties["labels"] 193 | assert.NotNil(t, labelsSchema.AdditionalProperties) 194 | assert.Equal(t, "string", labelsSchema.AdditionalProperties.Type) 195 | 196 | // Verify stringToInt object schema 197 | portsSchema := flagsSchema.Properties["ports"] 198 | assert.NotNil(t, portsSchema.AdditionalProperties) 199 | assert.Equal(t, "integer", portsSchema.AdditionalProperties.Type) 200 | 201 | // Verify persistent flag from parent command 202 | assert.Contains(t, flagsSchema.Properties, "config", "Should include persistent flag from parent command") 203 | 204 | // Verify args schema 205 | argsSchema := inputSchema.Properties["args"] 206 | assert.Equal(t, "array", argsSchema.Type) 207 | assert.NotNil(t, argsSchema.Items) 208 | assert.Equal(t, "string", argsSchema.Items.Type) 209 | }) 210 | 211 | t.Run("Restricted Selector", func(t *testing.T) { 212 | // Create a selector that only allows specific flags 213 | selector := Selector{ 214 | LocalFlagSelector: func(flag *pflag.Flag) bool { 215 | names := []string{"output", "verbose", "hidden", "old"} 216 | return slices.Contains(names, flag.Name) 217 | }, 218 | InheritedFlagSelector: func(_ *pflag.Flag) bool { return false }, 219 | } 220 | 221 | // Create tool from command with the restricted selector 222 | tool := selector.createToolFromCmd(cmd) 223 | 224 | // Verify tool properties 225 | assert.Equal(t, "parent_test", tool.Name) 226 | assert.Contains(t, tool.Description, "This is a test command") 227 | assert.Contains(t, tool.Description, "test file.txt --output result.txt") 228 | assert.NotNil(t, tool.InputSchema) 229 | 230 | // Verify schema structure 231 | inputSchema := tool.InputSchema.(*jsonschema.Schema) 232 | assert.Equal(t, "object", inputSchema.Type) 233 | require.NotNil(t, inputSchema.Properties) 234 | assert.Contains(t, inputSchema.Properties, "flags") 235 | assert.Contains(t, inputSchema.Properties, "args") 236 | 237 | // Verify flags schema 238 | flagsSchema := inputSchema.Properties["flags"] 239 | require.NotNil(t, flagsSchema.Properties) 240 | assert.Contains(t, flagsSchema.Properties, "output") 241 | assert.Contains(t, flagsSchema.Properties, "verbose") 242 | 243 | // Verify excluded flags 244 | assert.NotContains(t, flagsSchema.Properties, "hidden", "Should not include hidden flag") 245 | assert.NotContains(t, flagsSchema.Properties, "old", "Should not include deprecated flag") 246 | assert.NotContains(t, flagsSchema.Properties, "include", "Should not include excluded flag") 247 | assert.NotContains(t, flagsSchema.Properties, "count", "Should not include excluded flag") 248 | assert.NotContains(t, flagsSchema.Properties, "config", "Should not include excluded persistent flag") 249 | assert.NotContains(t, flagsSchema.Properties, "greeting", "Should not include excluded flag") 250 | assert.NotContains(t, flagsSchema.Properties, "labels", "Should not include excluded flag") 251 | assert.NotContains(t, flagsSchema.Properties, "ports", "Should not include excluded flag") 252 | 253 | // Verify required flags - none should be required since 'count' was excluded 254 | require.Empty(t, flagsSchema.Required, "Should have no required flags") 255 | 256 | // Verify args schema 257 | argsSchema := inputSchema.Properties["args"] 258 | assert.Equal(t, "array", argsSchema.Type) 259 | assert.NotNil(t, argsSchema.Items) 260 | assert.Equal(t, "string", argsSchema.Items.Type) 261 | }) 262 | } 263 | 264 | func TestGenerateToolName(t *testing.T) { 265 | root := &cobra.Command{ 266 | Use: "root", 267 | } 268 | child := &cobra.Command{ 269 | Use: "child", 270 | } 271 | grandchild := &cobra.Command{ 272 | Use: "grandchild", 273 | } 274 | 275 | root.AddCommand(child) 276 | child.AddCommand(grandchild) 277 | name := toolName(grandchild) 278 | assert.Equal(t, "root_child_grandchild", name) 279 | } 280 | 281 | func TestGenerateToolDescription(t *testing.T) { 282 | t.Run("Long and Example", func(t *testing.T) { 283 | cmd1 := &cobra.Command{ 284 | Use: "cmd1", 285 | Short: "Short description", 286 | Long: "Long description of cmd1", 287 | Example: "cmd1 --help", 288 | } 289 | desc1 := toolDescription(cmd1) 290 | assert.Contains(t, desc1, "Long description of cmd1") 291 | assert.Contains(t, desc1, "Examples:\ncmd1 --help") 292 | }) 293 | 294 | t.Run("Short only", func(t *testing.T) { 295 | cmd2 := &cobra.Command{ 296 | Use: "cmd2", 297 | Short: "Short description of cmd2", 298 | } 299 | desc2 := toolDescription(cmd2) 300 | assert.Equal(t, "Short description of cmd2", desc2) 301 | }) 302 | } 303 | --------------------------------------------------------------------------------