feat: add amcs-cli — MCP tool CLI and stdio bridge
- cmd/amcs-cli: new CLI tool for human/AI MCP tool access - amcs-cli tools: list all tools from remote MCP server - amcs-cli call <tool> --arg k=v: invoke a tool, print JSON/YAML result - amcs-cli stdio: stdio→HTTP MCP bridge for AI clients - Config: ~/.config/amcs/config.yaml, AMCS_URL/AMCS_TOKEN env vars, --server/--token flags - Token never logged in errors - Makefile: add build-cli target Closes #18
This commit is contained in:
98
cmd/amcs-cli/cmd/call.go
Normal file
98
cmd/amcs-cli/cmd/call.go
Normal file
@@ -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 <tool>",
|
||||
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
|
||||
}
|
||||
}
|
||||
60
cmd/amcs-cli/cmd/config.go
Normal file
60
cmd/amcs-cli/cmd/config.go
Normal file
@@ -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
|
||||
}
|
||||
134
cmd/amcs-cli/cmd/root.go
Normal file
134
cmd/amcs-cli/cmd/root.go
Normal file
@@ -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
|
||||
}
|
||||
62
cmd/amcs-cli/cmd/stdio.go
Normal file
62
cmd/amcs-cli/cmd/stdio.go
Normal file
@@ -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)
|
||||
}
|
||||
38
cmd/amcs-cli/cmd/tools.go
Normal file
38
cmd/amcs-cli/cmd/tools.go
Normal file
@@ -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)
|
||||
}
|
||||
7
cmd/amcs-cli/main.go
Normal file
7
cmd/amcs-cli/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "git.warky.dev/wdevs/amcs/cmd/amcs-cli/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
Reference in New Issue
Block a user