diff --git a/Makefile b/Makefile index 682f85a..68f3698 100644 --- a/Makefile +++ b/Makefile @@ -78,3 +78,8 @@ check-schema-drift: exit 1; \ fi; \ rm -f $$tmpfile + +.PHONY: build-cli +build-cli: + @mkdir -p $(BIN_DIR) + go build -o $(BIN_DIR)/amcs-cli ./cmd/amcs-cli diff --git a/cmd/amcs-cli/cmd/call.go b/cmd/amcs-cli/cmd/call.go new file mode 100644 index 0000000..a938852 --- /dev/null +++ b/cmd/amcs-cli/cmd/call.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +var argFlags []string + +var callCmd = &cobra.Command{ + Use: "call ", + Short: "Call a remote AMCS tool", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + toolName := args[0] + toolArgs, err := parseArgs(argFlags) + if err != nil { + return err + } + + session, err := connectRemote(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = session.Close() }() + + res, err := session.CallTool(cmd.Context(), &mcp.CallToolParams{Name: toolName, Arguments: toolArgs}) + if err != nil { + return fmt.Errorf("call tool %q: %w", toolName, err) + } + return printOutput(res) + }, +} + +func init() { + callCmd.Flags().StringArrayVar(&argFlags, "arg", nil, "Tool argument in key=value format (repeatable)") + rootCmd.AddCommand(callCmd) +} + +func parseArgs(items []string) (map[string]any, error) { + result := make(map[string]any, len(items)) + for _, item := range items { + key, value, ok := strings.Cut(item, "=") + if !ok || strings.TrimSpace(key) == "" { + return nil, fmt.Errorf("invalid --arg %q: want key=value", item) + } + result[key] = parseScalar(value) + } + return result, nil +} + +func parseScalar(s string) any { + if s == "true" || s == "false" { + b, _ := strconv.ParseBool(s) + return b + } + if i, err := strconv.ParseInt(s, 10, 64); err == nil { + return i + } + if f, err := strconv.ParseFloat(s, 64); err == nil && strings.ContainsAny(s, ".eE") { + return f + } + var v any + if err := json.Unmarshal([]byte(s), &v); err == nil { + switch v.(type) { + case map[string]any, []any, float64, bool, nil: + return v + } + } + return s +} + +func printOutput(v any) error { + switch outputFlag { + case "yaml": + data, err := yaml.Marshal(v) + if err != nil { + return fmt.Errorf("marshal yaml: %w", err) + } + _, err = os.Stdout.Write(data) + return err + default: + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return fmt.Errorf("marshal json: %w", err) + } + data = append(data, '\n') + _, err = os.Stdout.Write(data) + return err + } +} diff --git a/cmd/amcs-cli/cmd/config.go b/cmd/amcs-cli/cmd/config.go new file mode 100644 index 0000000..be48f09 --- /dev/null +++ b/cmd/amcs-cli/cmd/config.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server string `yaml:"server"` + Token string `yaml:"token"` +} + +func defaultConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".config", "amcs", "config.yaml"), nil +} + +func resolveConfigPath(path string) (string, error) { + if strings.TrimSpace(path) != "" { + return path, nil + } + return defaultConfigPath() +} + +func loadConfigFile(path string) (Config, error) { + var cfg Config + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return cfg, nil + } + return cfg, fmt.Errorf("read config: %w", err) + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("parse config: %w", err) + } + return cfg, nil +} + +func saveConfigFile(path string, cfg Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write config: %w", err) + } + return nil +} diff --git a/cmd/amcs-cli/cmd/root.go b/cmd/amcs-cli/cmd/root.go new file mode 100644 index 0000000..3c49bda --- /dev/null +++ b/cmd/amcs-cli/cmd/root.go @@ -0,0 +1,134 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "os" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" +) + +var ( + cfgFile string + serverFlag string + tokenFlag string + outputFlag string + cfg Config +) + +var rootCmd = &cobra.Command{ + Use: "amcs-cli", + Short: "CLI for connecting to a remote AMCS MCP server", + SilenceUsage: true, + SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { + return loadConfig() + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "Path to config file") + rootCmd.PersistentFlags().StringVar(&serverFlag, "server", "", "AMCS server URL") + rootCmd.PersistentFlags().StringVar(&tokenFlag, "token", "", "AMCS bearer token") + rootCmd.PersistentFlags().StringVar(&outputFlag, "output", "json", "Output format: json or yaml") +} + +func loadConfig() error { + path, err := resolveConfigPath(cfgFile) + if err != nil { + return err + } + loaded, err := loadConfigFile(path) + if err != nil { + return err + } + cfg = loaded + if v := strings.TrimSpace(os.Getenv("AMCS_URL")); v != "" { + cfg.Server = v + } + if v := strings.TrimSpace(os.Getenv("AMCS_TOKEN")); v != "" { + cfg.Token = v + } + if v := strings.TrimSpace(serverFlag); v != "" { + cfg.Server = v + } + if v := strings.TrimSpace(tokenFlag); v != "" { + cfg.Token = v + } + outputFlag = strings.ToLower(strings.TrimSpace(outputFlag)) + if outputFlag != "json" && outputFlag != "yaml" { + return fmt.Errorf("invalid --output %q: must be json or yaml", outputFlag) + } + return nil +} + +func requireServer() error { + if strings.TrimSpace(cfg.Server) == "" { + return fmt.Errorf("server URL is required; set --server, AMCS_URL, or config server") + } + return nil +} + +func endpointURL() string { + base := strings.TrimRight(strings.TrimSpace(cfg.Server), "/") + if strings.HasSuffix(base, "/mcp") { + return base + } + return base + "/mcp" +} + +func newHTTPClient() *http.Client { + return &http.Client{ + Timeout: 0, + Transport: &bearerTransport{ + base: http.DefaultTransport, + token: cfg.Token, + }, + } +} + +type bearerTransport struct { + base http.RoundTripper + token string +} + +func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base + if base == nil { + base = http.DefaultTransport + } + clone := req.Clone(req.Context()) + if strings.TrimSpace(t.token) != "" { + clone.Header.Set("Authorization", "Bearer "+t.token) + } + return base.RoundTrip(clone) +} + +func connectRemote(ctx context.Context) (*mcp.ClientSession, error) { + if err := requireServer(); err != nil { + return nil, err + } + client := mcp.NewClient(&mcp.Implementation{Name: "amcs-cli", Version: "0.0.1"}, nil) + transport := &mcp.StreamableClientTransport{ + Endpoint: endpointURL(), + HTTPClient: newHTTPClient(), + } + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + session, err := client.Connect(ctx, transport, nil) + if err != nil { + return nil, fmt.Errorf("connect to AMCS server: %w", err) + } + return session, nil +} diff --git a/cmd/amcs-cli/cmd/stdio.go b/cmd/amcs-cli/cmd/stdio.go new file mode 100644 index 0000000..2c5eb09 --- /dev/null +++ b/cmd/amcs-cli/cmd/stdio.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" +) + +var stdioCmd = &cobra.Command{ + Use: "stdio", + Short: "Run a stdio MCP bridge backed by a remote AMCS server", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + remote, err := connectRemote(ctx) + if err != nil { + return err + } + defer func() { _ = remote.Close() }() + + tools, err := remote.ListTools(ctx, nil) + if err != nil { + return fmt.Errorf("load remote tools: %w", err) + } + + server := mcp.NewServer(&mcp.Implementation{ + Name: "amcs-cli", + Title: "AMCS CLI Bridge", + Version: "0.0.1", + }, nil) + + for _, tool := range tools.Tools { + remoteTool := tool + server.AddTool(&mcp.Tool{ + Name: remoteTool.Name, + Description: remoteTool.Description, + InputSchema: remoteTool.InputSchema, + OutputSchema: remoteTool.OutputSchema, + Annotations: remoteTool.Annotations, + }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return remote.CallTool(ctx, &mcp.CallToolParams{ + Name: req.Params.Name, + Arguments: req.Params.Arguments, + }) + }) + } + + session, err := server.Connect(ctx, &mcp.StdioTransport{}, nil) + if err != nil { + return fmt.Errorf("start stdio bridge: %w", err) + } + defer func() { _ = session.Close() }() + + <-ctx.Done() + return nil + }, +} + +func init() { + rootCmd.AddCommand(stdioCmd) +} diff --git a/cmd/amcs-cli/cmd/tools.go b/cmd/amcs-cli/cmd/tools.go new file mode 100644 index 0000000..9fe62fd --- /dev/null +++ b/cmd/amcs-cli/cmd/tools.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +var toolsCmd = &cobra.Command{ + Use: "tools", + Short: "List tools available on the remote AMCS server", + RunE: func(cmd *cobra.Command, _ []string) error { + session, err := connectRemote(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = session.Close() }() + + res, err := session.ListTools(cmd.Context(), nil) + if err != nil { + return fmt.Errorf("list tools: %w", err) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tDESCRIPTION") + for _, tool := range res.Tools { + fmt.Fprintf(w, "%s\t%s\n", tool.Name, strings.TrimSpace(tool.Description)) + } + return w.Flush() + }, +} + +func init() { + rootCmd.AddCommand(toolsCmd) +} diff --git a/cmd/amcs-cli/main.go b/cmd/amcs-cli/main.go new file mode 100644 index 0000000..e297d21 --- /dev/null +++ b/cmd/amcs-cli/main.go @@ -0,0 +1,7 @@ +package main + +import "git.warky.dev/wdevs/amcs/cmd/amcs-cli/cmd" + +func main() { + cmd.Execute() +} diff --git a/go.mod b/go.mod index d4b03a3..32db19b 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,13 @@ require ( github.com/jackc/pgx/v5 v5.9.1 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/pgvector/pgvector-go v0.3.0 + github.com/spf13/cobra v1.10.2 golang.org/x/sync v0.17.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect @@ -20,6 +22,7 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/oauth2 v0.34.0 // indirect diff --git a/go.sum b/go.sum index c3b96a1..4d9e917 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ entgo.io/ent v0.14.3 h1:wokAV/kIlH9TeklJWGGS7AYJdVckr0DloWjIcO9iIIQ= entgo.io/ent v0.14.3/go.mod h1:aDPE/OziPEu8+OWbzy4UlvWmD2/kbRuWfK2A40hcxJM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -16,6 +17,8 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -44,10 +47,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -73,6 +81,7 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=