249 lines
7.3 KiB
Go
249 lines
7.3 KiB
Go
package mcp
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
type addOptions struct {
|
|
Env []string
|
|
EnvFile string
|
|
Headers []string
|
|
Transport string
|
|
Force bool
|
|
Deferred *bool // nil = not set, true = deferred, false = not deferred
|
|
}
|
|
|
|
func newAddCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "add [flags] <name> <command-or-url> [args...]",
|
|
Short: "Add or update an MCP server",
|
|
DisableFlagParsing: true,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts, name, target, targetArgs, showHelp, err := parseAddArgs(args)
|
|
if showHelp {
|
|
return cmd.Help()
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if cfg.Tools.MCP.Servers == nil {
|
|
cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig)
|
|
}
|
|
|
|
if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force {
|
|
var overwrite bool
|
|
|
|
overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to confirm overwrite: %w", err)
|
|
}
|
|
if !overwrite {
|
|
return fmt.Errorf("aborted: MCP server %q already exists", name)
|
|
}
|
|
}
|
|
|
|
server, err := buildServerConfig(target, targetArgs, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfg.Tools.MCP.Enabled = true
|
|
cfg.Tools.MCP.Servers[name] = server
|
|
|
|
if err := saveValidatedConfig(cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name)
|
|
return nil
|
|
},
|
|
}
|
|
|
|
flags := cmd.Flags()
|
|
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
|
|
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
|
|
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
|
|
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse")
|
|
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
|
|
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
|
|
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) {
|
|
opts := addOptions{Transport: "stdio"}
|
|
var positional []string
|
|
serverArgs := make([]string, 0)
|
|
explicitCommand := make([]string, 0)
|
|
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
|
|
switch {
|
|
case arg == "--help" || arg == "-h":
|
|
return addOptions{}, "", "", nil, true, nil
|
|
case arg == "--":
|
|
if i+1 < len(args) {
|
|
explicitCommand = append(explicitCommand, args[i+1:]...)
|
|
}
|
|
i = len(args)
|
|
case arg == "--force" || arg == "-f":
|
|
opts.Force = true
|
|
case arg == "--deferred":
|
|
t := true
|
|
opts.Deferred = &t
|
|
case arg == "--no-deferred":
|
|
f := false
|
|
opts.Deferred = &f
|
|
case arg == "--transport" || arg == "-t":
|
|
if i+1 >= len(args) {
|
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
}
|
|
i++
|
|
opts.Transport = args[i]
|
|
case strings.HasPrefix(arg, "--transport="):
|
|
opts.Transport = strings.TrimPrefix(arg, "--transport=")
|
|
case arg == "--env" || arg == "-e":
|
|
if i+1 >= len(args) {
|
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
}
|
|
i++
|
|
opts.Env = append(opts.Env, args[i])
|
|
case arg == "--env-file":
|
|
if i+1 >= len(args) {
|
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
}
|
|
i++
|
|
opts.EnvFile = args[i]
|
|
case strings.HasPrefix(arg, "--env="):
|
|
opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env="))
|
|
case strings.HasPrefix(arg, "--env-file="):
|
|
opts.EnvFile = strings.TrimPrefix(arg, "--env-file=")
|
|
case arg == "--header" || arg == "-H":
|
|
if i+1 >= len(args) {
|
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
|
|
}
|
|
i++
|
|
opts.Headers = append(opts.Headers, args[i])
|
|
case strings.HasPrefix(arg, "--header="):
|
|
opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header="))
|
|
case strings.HasPrefix(arg, "-") && len(positional) >= 2:
|
|
serverArgs = append(serverArgs, args[i:]...)
|
|
i = len(args)
|
|
default:
|
|
positional = append(positional, arg)
|
|
}
|
|
}
|
|
|
|
if len(explicitCommand) > 0 {
|
|
if len(positional) != 1 {
|
|
return addOptions{}, "", "", nil, false, fmt.Errorf(
|
|
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
|
|
)
|
|
}
|
|
if len(explicitCommand) == 0 {
|
|
return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --")
|
|
}
|
|
return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil
|
|
}
|
|
|
|
if len(positional) < 2 {
|
|
return addOptions{}, "", "", nil, false, fmt.Errorf(
|
|
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
|
|
)
|
|
}
|
|
|
|
targetArgs := make([]string, 0, len(positional)-2+len(serverArgs))
|
|
targetArgs = append(targetArgs, positional[2:]...)
|
|
targetArgs = append(targetArgs, serverArgs...)
|
|
|
|
return opts, positional[0], positional[1], targetArgs, false, nil
|
|
}
|
|
|
|
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
|
|
transport := strings.ToLower(strings.TrimSpace(opts.Transport))
|
|
if transport == "" {
|
|
transport = "stdio"
|
|
}
|
|
switch transport {
|
|
case "stdio", "http", "sse":
|
|
default:
|
|
return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport)
|
|
}
|
|
|
|
env, err := parseEnvAssignments(opts.Env)
|
|
if err != nil {
|
|
return config.MCPServerConfig{}, err
|
|
}
|
|
headers, err := parseHeaderAssignments(opts.Headers)
|
|
if err != nil {
|
|
return config.MCPServerConfig{}, err
|
|
}
|
|
|
|
server := config.MCPServerConfig{
|
|
Enabled: true,
|
|
Type: transport,
|
|
Deferred: opts.Deferred,
|
|
}
|
|
|
|
switch transport {
|
|
case "http", "sse":
|
|
if len(env) > 0 {
|
|
return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport")
|
|
}
|
|
if strings.TrimSpace(opts.EnvFile) != "" {
|
|
return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport")
|
|
}
|
|
if len(args) > 0 {
|
|
return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport)
|
|
}
|
|
parsedURL, err := url.ParseRequestURI(target)
|
|
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
|
|
return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target)
|
|
}
|
|
server.URL = target
|
|
server.Headers = headers
|
|
return server, nil
|
|
}
|
|
|
|
if len(headers) > 0 {
|
|
return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport")
|
|
}
|
|
|
|
if looksLikeRemoteURL(target) {
|
|
return config.MCPServerConfig{}, fmt.Errorf(
|
|
"target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse",
|
|
target,
|
|
transport,
|
|
)
|
|
}
|
|
|
|
command := target
|
|
commandArgs := append([]string(nil), args...)
|
|
|
|
if err := validateLocalCommandPath(target); err != nil {
|
|
return config.MCPServerConfig{}, err
|
|
}
|
|
if isLocalCommandPath(command) {
|
|
command = expandHomePath(command)
|
|
}
|
|
|
|
server.Command = command
|
|
server.Args = commandArgs
|
|
server.Env = env
|
|
server.EnvFile = strings.TrimSpace(opts.EnvFile)
|
|
|
|
return server, nil
|
|
}
|