diff --git a/README.md b/README.md index 5aac4bbc9..9668fcf34 100644 --- a/README.md +++ b/README.md @@ -571,7 +571,19 @@ PicoClaw natively supports [MCP](https://modelcontextprotocol.io/) — connect a } ``` -For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). +You can manage common MCP setups directly from the CLI instead of editing JSON by hand: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +picoclaw mcp list +picoclaw mcp test filesystem +``` + +`picoclaw mcp` is a configuration manager: it updates `config.json` under `tools.mcp.servers`, but it does not keep the server process running itself. + +Use `picoclaw mcp edit` when you need advanced fields such as `headers`, `env_file`, or `deferred`. + +For full MCP configuration (stdio, SSE, HTTP transports, Tool Discovery), see [Tools Configuration - MCP](docs/reference/tools_configuration.md#mcp-tool). For CLI usage and examples, see [MCP Server CLI](docs/reference/mcp-cli.md). ## ClawdChat Join the Agent Social Network @@ -591,6 +603,11 @@ Connect PicoClaw to the Agent Social Network simply by sending a single message | `picoclaw status` | Show status | | `picoclaw version` | Show version info | | `picoclaw model` | View or switch the default model | +| `picoclaw mcp list` | List configured MCP servers | +| `picoclaw mcp add ...` | Add or update an MCP server entry | +| `picoclaw mcp test` | Probe a configured MCP server | +| `picoclaw mcp edit` | Open config for advanced MCP editing | +| `picoclaw mcp remove` | Remove an MCP server entry | | `picoclaw cron list` | List all scheduled jobs | | `picoclaw cron add ...` | Add a scheduled job | | `picoclaw cron disable` | Disable a scheduled job | @@ -619,6 +636,7 @@ For detailed guides beyond this README: | [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes | | [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides | | [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox | +| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI | | [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage | | [Providers & Models](docs/guides/providers.md) | 30+ LLM providers, model routing, model_list configuration | | [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration | diff --git a/cmd/picoclaw/internal/cliui/mcp_show.go b/cmd/picoclaw/internal/cliui/mcp_show.go new file mode 100644 index 000000000..5d5af1e75 --- /dev/null +++ b/cmd/picoclaw/internal/cliui/mcp_show.go @@ -0,0 +1,384 @@ +package cliui + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// MCPShowServer holds the server metadata for PrintMCPShow. +type MCPShowServer struct { + Name string + Type string + Target string + Enabled bool + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global + EnvKeys []string // sorted env var names (values intentionally omitted) + EnvFile string + Headers []string // sorted header names +} + +// MCPShowTool holds one tool's info for PrintMCPShow. +type MCPShowTool struct { + Name string + Description string + Parameters []MCPShowParam +} + +// MCPShowParam is one parameter entry. +type MCPShowParam struct { + Name string + Type string + Description string + Required bool +} + +// PrintMCPShow renders the mcp show output (plain or fancy). +// w is where the output is written; pass cmd.OutOrStdout() from cobra commands. +func PrintMCPShow(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + if !UseFancyLayout() { + printMCPShowPlain(w, server, tools, disabled) + return + } + printMCPShowFancy(w, server, tools, disabled) +} + +// ── plain (narrow / non-TTY) ──────────────────────────────────────────────── + +func printMCPShowPlain(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + fmt.Fprintf(w, "Server: %s\n", server.Name) + fmt.Fprintf(w, "Type: %s\n", server.Type) + fmt.Fprintf(w, "Target: %s\n", server.Target) + fmt.Fprintf(w, "Enabled: %s\n", boolWord(server.Enabled)) + deferredLabel := boolWord(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredLabel += " (default)" + } + fmt.Fprintf(w, "Deferred: %s\n", deferredLabel) + if len(server.EnvKeys) > 0 { + fmt.Fprintf(w, "Env vars: %s\n", strings.Join(server.EnvKeys, ", ")) + } + if server.EnvFile != "" { + fmt.Fprintf(w, "Env file: %s\n", server.EnvFile) + } + if len(server.Headers) > 0 { + fmt.Fprintf(w, "Headers: %s\n", strings.Join(server.Headers, ", ")) + } + fmt.Fprintln(w) + + if disabled { + fmt.Fprintln(w, "Server is disabled; skipping tool discovery.") + return + } + if len(tools) == 0 { + fmt.Fprintln(w, "No tools exposed by this server.") + return + } + + fmt.Fprintf(w, "Tools (%d):\n", len(tools)) + for _, tool := range tools { + fmt.Fprintf(w, " %s\n", tool.Name) + if tool.Description != "" { + fmt.Fprintf(w, " %s\n", truncateDescription(tool.Description, 120)) + } + if len(tool.Parameters) == 0 { + fmt.Fprintln(w, " Parameters: none") + continue + } + for _, p := range tool.Parameters { + line := fmt.Sprintf(" - %s", p.Name) + if p.Type != "" { + line += fmt.Sprintf(" (%s", p.Type) + if p.Required { + line += ", required" + } + line += ")" + } else if p.Required { + line += " (required)" + } + if p.Description != "" { + line += ": " + truncateDescription(p.Description, 80) + } + fmt.Fprintln(w, line) + } + } +} + +// ── fancy (wide TTY) ──────────────────────────────────────────────────────── + +var ( + mcpToolNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentBlue).Bold(true) + } + mcpParamNameStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(accentRed).Bold(true) + } + mcpTagStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")) + } + mcpRequiredStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + } + mcpOptionalStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + } + mcpDescStyle = func() lipgloss.Style { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC")) + } +) + +func printMCPShowFancy(w io.Writer, server MCPShowServer, tools []MCPShowTool, disabled bool) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + // ── server header ── + b.WriteString(titleBarStyle().Render("⬡ " + server.Name)) + b.WriteString("\n\n") + + keyW := 10 + writeKV := func(key, val string) { + k := kvKeyStyle().Width(keyW).Render(key) + b.WriteString(k + " " + val + "\n") + } + + writeKV("Type", server.Type) + writeKV("Target", server.Target) + writeKV("Enabled", coloredBool(server.Enabled)) + deferredVal := coloredBool(server.EffectiveDeferred) + if !server.DeferredExplicit { + deferredVal += " " + mcpTagStyle().Render("(default)") + } + writeKV("Deferred", deferredVal) + if len(server.EnvKeys) > 0 { + writeKV("Env vars", mutedStyle().Render(strings.Join(server.EnvKeys, ", "))) + } + if server.EnvFile != "" { + writeKV("Env file", mutedStyle().Render(server.EnvFile)) + } + if len(server.Headers) > 0 { + writeKV("Headers", mutedStyle().Render(strings.Join(server.Headers, ", "))) + } + + if disabled { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("Server is disabled; skipping tool discovery.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + if len(tools) == 0 { + b.WriteString("\n") + b.WriteString(mutedStyle().Render("No tools exposed by this server.")) + fmt.Fprintln(w, box.Render(b.String())) + return + } + + // ── tools section ── + b.WriteString("\n") + b.WriteString(kvKeyStyle().Render(fmt.Sprintf("Tools (%d)", len(tools)))) + b.WriteString("\n") + + contentW := inner - 4 // account for box padding + for i, tool := range tools { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + // Tool name + index badge + badge := mcpTagStyle().Render(fmt.Sprintf("[%d/%d]", i+1, len(tools))) + b.WriteString(" " + mcpToolNameStyle().Render(tool.Name) + " " + badge + "\n") + + // Description (wrapped to content width) + if tool.Description != "" { + desc := truncateDescription(tool.Description, 160) + b.WriteString(" " + mcpDescStyle().Render(desc) + "\n") + } + + // Parameters + if len(tool.Parameters) == 0 { + b.WriteString(" " + mcpTagStyle().Render("no parameters") + "\n") + continue + } + + b.WriteString("\n") + for _, p := range tool.Parameters { + // name + pName := mcpParamNameStyle().Render(p.Name) + + // type tag + typeTag := "" + if p.Type != "" { + typeTag = " " + mcpTagStyle().Render("<"+p.Type+">") + } + + // required / optional badge + var reqBadge string + if p.Required { + reqBadge = " " + mcpRequiredStyle().Render("required") + } else { + reqBadge = " " + mcpOptionalStyle().Render("optional") + } + + b.WriteString(" " + pName + typeTag + reqBadge + "\n") + + if p.Description != "" { + desc := truncateDescription(p.Description, 120) + b.WriteString(" " + mutedStyle().Render(desc) + "\n") + } + } + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +// ── mcp list ──────────────────────────────────────────────────────────────── + +// MCPListRow is one row in the mcp list output. +type MCPListRow struct { + Name string + Type string + Target string + Status string // "enabled", "disabled", "ok (N tools)", "error" + EffectiveDeferred bool // resolved value (per-server override or global default) + DeferredExplicit bool // true = per-server override set, false = inherited from global +} + +// PrintMCPList renders the mcp list output (plain or fancy). +func PrintMCPList(w io.Writer, rows []MCPListRow) { + if !UseFancyLayout() { + printMCPListPlain(w, rows) + return + } + printMCPListFancy(w, rows) +} + +func printMCPListPlain(w io.Writer, rows []MCPListRow) { + headers := []string{"Name", "Type", "Command", "Status", "Deferred"} + tableRows := make([][]string, len(rows)) + for i, r := range rows { + deferred := boolWord(r.EffectiveDeferred) + if !r.DeferredExplicit { + deferred += " (default)" + } + tableRows[i] = []string{r.Name, r.Type, r.Target, r.Status, deferred} + } + // reuse the ASCII table renderer already in helpers.go via the caller + // (list.go still uses renderTable for the plain path) + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range tableRows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + border := func() { + fmt.Fprint(w, "+") + for _, width := range widths { + fmt.Fprint(w, strings.Repeat("-", width+2)+"+") + } + fmt.Fprintln(w) + } + writeRow := func(row []string) { + fmt.Fprint(w, "|") + for i, cell := range row { + fmt.Fprintf(w, " %s%s |", cell, strings.Repeat(" ", widths[i]-len(cell))) + } + fmt.Fprintln(w) + } + border() + writeRow(headers) + border() + for _, row := range tableRows { + writeRow(row) + } + border() +} + +func printMCPListFancy(w io.Writer, rows []MCPListRow) { + inner := InnerWidth() + box := borderStyle().Width(inner) + + var b strings.Builder + + title := fmt.Sprintf("MCP Servers (%d)", len(rows)) + b.WriteString(titleBarStyle().Render(title)) + b.WriteString("\n") + + contentW := inner - 4 + for i, row := range rows { + if i > 0 { + b.WriteString(strings.Repeat("─", contentW) + "\n") + } + b.WriteString("\n") + + statusBadge := mcpListStatusStyle(row.Status).Render(row.Status) + var deferredBadge string + if row.EffectiveDeferred { + if row.DeferredExplicit { + deferredBadge = " " + mcpTagStyle().Render("deferred") + } else { + deferredBadge = " " + mcpOptionalStyle().Render("deferred (default)") + } + } + b.WriteString(" " + mcpToolNameStyle().Render(row.Name) + " " + statusBadge + deferredBadge + "\n") + b.WriteString(" " + mcpTagStyle().Render(row.Type+" "+row.Target) + "\n") + } + + fmt.Fprintln(w, box.Render(b.String())) +} + +func mcpListStatusStyle(status string) lipgloss.Style { + switch { + case status == "enabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "disabled": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#6B6B6B")) + case strings.HasPrefix(status, "ok"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true) + case status == "error": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Bold(true) + default: + return lipgloss.NewStyle() + } +} + +// ── helpers ───────────────────────────────────────────────────────────────── + +func boolWord(v bool) string { + if v { + return "yes" + } + return "no" +} + +func coloredBool(v bool) string { + if v { + return lipgloss.NewStyle().Foreground(lipgloss.Color("#2E7D32")).Bold(true).Render("yes") + } + return lipgloss.NewStyle().Foreground(lipgloss.Color("#D54646")).Render("no") +} + +// truncateDescription strips newlines, collapses whitespace, and caps length. +func truncateDescription(s string, maxLen int) string { + // collapse newlines and repeated spaces into a single space + s = strings.Join(strings.Fields(s), " ") + if len(s) <= maxLen { + return s + } + // cut at last space before maxLen + cut := s[:maxLen] + if idx := strings.LastIndex(cut, " "); idx > maxLen/2 { + cut = cut[:idx] + } + return cut + "…" +} diff --git a/cmd/picoclaw/internal/mcp/add.go b/cmd/picoclaw/internal/mcp/add.go new file mode 100644 index 000000000..57e8f5368 --- /dev/null +++ b/cmd/picoclaw/internal/mcp/add.go @@ -0,0 +1,232 @@ +package mcp + +import ( + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type addOptions struct { + Env []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] [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)") + 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 strings.HasPrefix(arg, "--env="): + opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env=")) + 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] [args...] or picoclaw mcp add [flags] -- [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] [args...] or picoclaw mcp add [flags] -- [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 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 + } + + server.Command = command + server.Args = commandArgs + server.Env = env + + return server, nil +} diff --git a/cmd/picoclaw/internal/mcp/command.go b/cmd/picoclaw/internal/mcp/command.go new file mode 100644 index 000000000..d6e21181a --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command.go @@ -0,0 +1,25 @@ +package mcp + +import "github.com/spf13/cobra" + +func NewMCPCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "mcp", + Short: "Manage MCP server configuration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand( + newAddCommand(), + newRemoveCommand(), + newListCommand(), + newEditCommand(), + newTestCommand(), + newShowCommand(), + ) + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/command_test.go b/cmd/picoclaw/internal/mcp/command_test.go new file mode 100644 index 000000000..6fd8a6f58 --- /dev/null +++ b/cmd/picoclaw/internal/mcp/command_test.go @@ -0,0 +1,557 @@ +package mcp + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewMCPCommand(t *testing.T) { + cmd := NewMCPCommand() + + require.NotNil(t, cmd) + + assert.Equal(t, "mcp", cmd.Use) + assert.Equal(t, "Manage MCP server configuration", cmd.Short) + assert.True(t, cmd.HasSubCommands()) + + allowedCommands := []string{ + "add", + "remove", + "list", + "edit", + "test", + "show", + } + + subcommands := cmd.Commands() + assert.Len(t, subcommands, len(allowedCommands)) + + for _, subcmd := range subcommands { + found := slices.Contains(allowedCommands, subcmd.Name()) + assert.True(t, found, "unexpected subcommand %q", subcmd.Name()) + assert.False(t, subcmd.Hidden) + } +} + +func TestMCPAddAddsGenericStdioServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{ + "add", + "sqlite", + "npx", + "-y", + "@modelcontextprotocol/server-sqlite", + "--db", + "./mydb.db", + }, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "sqlite" saved`) + + cfg := readMCPConfig(t, configPath) + require.True(t, cfg.Tools.MCP.Enabled) + + server, ok := cfg.Tools.MCP.Servers["sqlite"] + require.True(t, ok) + assert.True(t, server.Enabled) + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"}, server.Args) +} + +func TestMCPAddSupportsHeadersAfterURL(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "apify", + "https://mcp.apify.com/", + "-t", + "http", + "--header", + "Authorization: Bearer OMITTED", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["apify"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.apify.com/", server.URL) + assert.Equal(t, map[string]string{"Authorization": "Bearer OMITTED"}, server.Headers) +} + +func TestMCPAddSupportsTransportBeforeName(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "sse", + "fiscal-ai", + "https://api.fiscal.ai/mcp/sse", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["fiscal-ai"] + assert.Equal(t, "sse", server.Type) + assert.Equal(t, "https://api.fiscal.ai/mcp/sse", server.URL) +} + +func TestMCPAddSupportsExplicitStdioCommandAfterSeparator(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "--transport", + "stdio", + "--env", + "AIRTABLE_API_KEY=YOUR_KEY", + "airtable", + "--", + "npx", + "-y", + "airtable-mcp-server", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["airtable"] + assert.Equal(t, "stdio", server.Type) + assert.Equal(t, "npx", server.Command) + assert.Equal(t, []string{"-y", "airtable-mcp-server"}, server.Args) + assert.Equal(t, map[string]string{"AIRTABLE_API_KEY": "YOUR_KEY"}, server.Env) +} + +func TestMCPAddRejectsNonExecutableLocalCommand(t *testing.T) { + setupMCPConfigEnv(t) + + tmpDir := t.TempDir() + localCmd := filepath.Join(tmpDir, "server.sh") + require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o644)) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "local", localCmd}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "not executable") +} + +func TestMCPAddShowsClearErrorForRemoteURLWithoutTransport(t *testing.T) { + setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "apify", "https://mcp.apify.com/"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `looks like a remote MCP URL`) + assert.Contains(t, err.Error(), `Use --transport http or --transport sse`) +} + +func TestMCPAddOverwritePromptDecline(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "n\n") + require.Error(t, err) + assert.Contains(t, output, `Overwrite? [y/N]:`) + assert.Contains(t, err.Error(), "aborted") + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "old", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddOverwriteWithConfirmation(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "old", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "filesystem", "new-command"}, "y\n") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + assert.Equal(t, "new-command", cfg.Tools.MCP.Servers["filesystem"].Command) +} + +func TestMCPAddHTTPServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{ + "add", + "context7", + "--transport", + "http", + "https://mcp.context7.com/mcp", + }, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["context7"] + assert.Equal(t, "http", server.Type) + assert.Equal(t, "https://mcp.context7.com/mcp", server.URL) + assert.Empty(t, server.Command) +} + +func TestMCPRemoveRemovesLastServerAndDisablesMCP(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"remove", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" removed`) + + cfg := readMCPConfig(t, configPath) + assert.False(t, cfg.Tools.MCP.Enabled) + assert.Empty(t, cfg.Tools.MCP.Servers) +} + +func TestMCPListPrintsTable(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "context7": { + Enabled: true, + Type: "http", + URL: "https://mcp.context7.com/mcp", + }, + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + Args: []string{"-y", "@modelcontextprotocol/server-filesystem", "/tmp"}, + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list"}, "") + require.NoError(t, err) + assert.Contains(t, output, "| Name") + assert.Contains(t, output, "context7") + assert.Contains(t, output, "filesystem") + assert.Contains(t, output, "https://mcp.context7.com/mcp") + assert.Contains(t, output, "disabled") +} + +func TestMCPListWithStatusUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, server config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + assert.Equal(t, "npx", server.Command) + return probeResult{ToolCount: 3}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"list", "--status"}, "") + require.NoError(t, err) + assert.Contains(t, output, "ok (3 tools)") +} + +func TestMCPEditUsesEditor(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + originalEditor := editorCommand + defer func() { editorCommand = originalEditor }() + + var gotName string + var gotArgs []string + editorCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return exec.Command("sh", "-c", "exit 0") + } + + t.Setenv("EDITOR", `dummy-editor --wait`) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.NoError(t, err) + + assert.Equal(t, "dummy-editor", gotName) + assert.Equal(t, []string{"--wait", configPath}, gotArgs) + _, statErr := os.Stat(configPath) + assert.NoError(t, statErr) +} + +func TestMCPEditRequiresEditor(t *testing.T) { + setupMCPConfigEnv(t) + t.Setenv("EDITOR", "") + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"edit"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "$EDITOR is not set") +} + +func TestMCPTestUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "filesystem": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + originalProbe := serverProbe + defer func() { serverProbe = originalProbe }() + serverProbe = func(_ context.Context, name string, _ config.MCPServerConfig, workspacePath string) (probeResult, error) { + assert.Equal(t, "filesystem", name) + assert.Equal(t, readMCPConfig(t, configPath).WorkspacePath(), workspacePath) + return probeResult{ToolCount: 2}, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"test", "filesystem"}, "") + require.NoError(t, err) + assert.Contains(t, output, `MCP server "filesystem" reachable (2 tools)`) +} + +func TestMCPAddDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.True(t, *server.Deferred) +} + +func TestMCPAddNoDeferredFlag(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "--no-deferred", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + require.NotNil(t, server.Deferred) + assert.False(t, *server.Deferred) +} + +func TestMCPAddNoDeferredByDefault(t *testing.T) { + configPath := setupMCPConfigEnv(t) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"add", "myserver", "npx", "my-mcp"}, "") + require.NoError(t, err) + + cfg := readMCPConfig(t, configPath) + server := cfg.Tools.MCP.Servers["myserver"] + assert.Nil(t, server.Deferred) +} + +func TestMCPShowNotFound(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, nil) + + cmd := NewMCPCommand() + _, err := executeCommand(cmd, []string{"show", "missing"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), `"missing" not found`) +} + +func TestMCPShowDisabledServer(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: false, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "disabled") +} + +func TestMCPShowUsesProbe(t *testing.T) { + configPath := setupMCPConfigEnv(t) + writeMCPConfig(t, configPath, &config.Config{ + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "myserver": { + Enabled: true, + Type: "stdio", + Command: "npx", + }, + }, + }, + }, + }) + + original := serverShowProbe + defer func() { serverShowProbe = original }() + serverShowProbe = func(_ context.Context, name string, _ config.MCPServerConfig, _ string) ([]toolDetail, error) { + assert.Equal(t, "myserver", name) + return []toolDetail{ + { + Name: "read_file", + Description: "Read a file from the filesystem", + Parameters: []paramDetail{ + {Name: "path", Type: "string", Description: "File path", Required: true}, + {Name: "encoding", Type: "string", Description: "Character encoding", Required: false}, + }, + }, + { + Name: "list_dir", + Description: "List directory contents", + Parameters: nil, + }, + }, nil + } + + cmd := NewMCPCommand() + output, err := executeCommand(cmd, []string{"show", "myserver"}, "") + require.NoError(t, err) + assert.Contains(t, output, "myserver") + assert.Contains(t, output, "read_file") + assert.Contains(t, output, "Read a file from the filesystem") + assert.Contains(t, output, "path") + assert.Contains(t, output, "string") + assert.Contains(t, output, "required") + assert.Contains(t, output, "list_dir") + assert.Contains(t, output, "none") +} + +func setupMCPConfigEnv(t *testing.T) string { + t.Helper() + + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv(config.EnvConfig, configPath) + t.Setenv(config.EnvHome, filepath.Dir(configPath)) + return configPath +} + +func writeMCPConfig(t *testing.T, path string, cfg *config.Config) { + t.Helper() + + if cfg == nil { + cfg = config.DefaultConfig() + } + + require.NoError(t, config.SaveConfig(path, cfg)) +} + +func readMCPConfig(t *testing.T, path string) *config.Config { + t.Helper() + + cfg, err := config.LoadConfig(path) + require.NoError(t, err) + return cfg +} + +func executeCommand(cmd *cobra.Command, args []string, stdin string) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd.SetArgs(args) + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetIn(strings.NewReader(stdin)) + + err := cmd.Execute() + return stdout.String() + stderr.String(), err +} diff --git a/cmd/picoclaw/internal/mcp/edit.go b/cmd/picoclaw/internal/mcp/edit.go new file mode 100644 index 000000000..06dcb6aef --- /dev/null +++ b/cmd/picoclaw/internal/mcp/edit.go @@ -0,0 +1,54 @@ +package mcp + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "go.mau.fi/util/shlex" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" +) + +func newEditCommand() *cobra.Command { + return &cobra.Command{ + Use: "edit", + Short: "Open the PicoClaw config in $EDITOR", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + editor := strings.TrimSpace(os.Getenv("EDITOR")) + if editor == "" { + return fmt.Errorf("$EDITOR is not set") + } + + cfg, err := loadConfig() + if err != nil { + return err + } + if err = saveValidatedConfig(cfg); err != nil { + return err + } + + editorArgs, err := shlex.Split(editor) + if err != nil { + return fmt.Errorf("failed to parse $EDITOR: %w", err) + } + if len(editorArgs) == 0 { + return fmt.Errorf("$EDITOR is empty") + } + + editorArgs = append(editorArgs, internal.GetConfigPath()) + process := editorCommand(editorArgs[0], editorArgs[1:]...) + process.Stdin = cmd.InOrStdin() + process.Stdout = cmd.OutOrStdout() + process.Stderr = cmd.ErrOrStderr() + + if err := process.Run(); err != nil { + return fmt.Errorf("failed to start editor: %w", err) + } + + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/helpers.go b/cmd/picoclaw/internal/mcp/helpers.go new file mode 100644 index 000000000..0fb0b245c --- /dev/null +++ b/cmd/picoclaw/internal/mcp/helpers.go @@ -0,0 +1,359 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type probeResult struct { + ToolCount int +} + +var ( + editorCommand = exec.Command + serverProbe = defaultServerProbe + + mcpConfigSchemaOnce sync.Once + mcpConfigSchema *jsonschema.Resolved + errMcpConfigSchema error +) + +const mcpConfigSchemaJSON = `{ + "type": "object", + "properties": { + "tools": { + "type": "object", + "properties": { + "mcp": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "discovery": { "type": "object", "additionalProperties": true }, + "max_inline_text_chars": { "type": "integer" }, + "servers": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { "type": "boolean" }, + "deferred": { "type": "boolean" }, + "command": { "type": "string" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "env": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "env_file": { "type": "string" }, + "type": { + "type": "string", + "enum": ["stdio", "http", "sse"] + }, + "url": { "type": "string" }, + "headers": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "required": ["enabled"], + "anyOf": [ + { "required": ["command"] }, + { "required": ["url"] } + ], + "additionalProperties": false + } + } + }, + "required": ["enabled"], + "additionalProperties": true + } + }, + "required": ["mcp"], + "additionalProperties": true + } + }, + "required": ["tools"], + "additionalProperties": true +}` + +func loadConfig() (*config.Config, error) { + cfg, err := config.LoadConfig(internal.GetConfigPath()) + if err != nil { + return nil, fmt.Errorf("failed to load config: %w", err) + } + return cfg, nil +} + +func saveValidatedConfig(cfg *config.Config) error { + if cfg == nil { + return fmt.Errorf("config is nil") + } + + data, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("failed to serialize config: %w", err) + } + + if err := validateConfigDocument(data); err != nil { + return err + } + + if err := config.SaveConfig(internal.GetConfigPath(), cfg); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + return nil +} + +func validateConfigDocument(data []byte) error { + var instance map[string]any + if err := json.Unmarshal(data, &instance); err != nil { + return fmt.Errorf("failed to decode serialized config: %w", err) + } + + schema, err := loadMCPConfigSchema() + if err != nil { + return fmt.Errorf("failed to load MCP config schema: %w", err) + } + + if err := schema.Validate(instance); err != nil { + return fmt.Errorf("config validation failed: %w", err) + } + + return nil +} + +func loadMCPConfigSchema() (*jsonschema.Resolved, error) { + mcpConfigSchemaOnce.Do(func() { + var schema jsonschema.Schema + if err := json.Unmarshal([]byte(mcpConfigSchemaJSON), &schema); err != nil { + errMcpConfigSchema = err + return + } + mcpConfigSchema, errMcpConfigSchema = schema.Resolve(nil) + }) + + return mcpConfigSchema, errMcpConfigSchema +} + +func inferTransportType(server config.MCPServerConfig) string { + switch server.Type { + case "stdio", "http", "sse": + return server.Type + } + if server.URL != "" { + return "sse" + } + if server.Command != "" { + return "stdio" + } + return "unknown" +} + +func renderServerTarget(server config.MCPServerConfig) string { + transport := inferTransportType(server) + if transport == "http" || transport == "sse" { + if server.URL == "" { + return "" + } + return server.URL + } + + parts := append([]string{server.Command}, server.Args...) + rendered := strings.TrimSpace(strings.Join(parts, " ")) + if rendered == "" { + return "" + } + return rendered +} + +func sortedServerNames(servers map[string]config.MCPServerConfig) []string { + names := make([]string, 0, len(servers)) + for name := range servers { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func parseEnvAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + env := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, "=") + if !found { + return nil, fmt.Errorf("invalid env assignment %q: expected KEY=value", entry) + } + key = strings.TrimSpace(key) + if key == "" { + return nil, fmt.Errorf("invalid env assignment %q: key cannot be empty", entry) + } + env[key] = value + } + + return env, nil +} + +func parseHeaderAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + headers := make(map[string]string, len(values)) + for _, entry := range values { + key, value, found := strings.Cut(entry, ":") + if !found { + key, value, found = strings.Cut(entry, "=") + } + if !found { + return nil, fmt.Errorf("invalid header %q: expected 'Name: Value' or 'Name=Value'", entry) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + if key == "" { + return nil, fmt.Errorf("invalid header %q: name cannot be empty", entry) + } + headers[key] = value + } + + return headers, nil +} + +func looksLikeRemoteURL(target string) bool { + parsedURL, err := url.ParseRequestURI(target) + if err != nil { + return false + } + if parsedURL.Host == "" { + return false + } + switch strings.ToLower(parsedURL.Scheme) { + case "http", "https": + return true + default: + return false + } +} + +func isLocalCommandPath(command string) bool { + if command == "" { + return false + } + if looksLikeRemoteURL(command) { + return false + } + return filepath.IsAbs(command) || + filepath.VolumeName(command) != "" || + strings.HasPrefix(command, "."+string(os.PathSeparator)) || + strings.HasPrefix(command, ".."+string(os.PathSeparator)) || + command == "." || + command == ".." || + strings.ContainsRune(command, os.PathSeparator) +} + +func expandHomePath(path string) string { + if path == "" || path[0] != '~' { + return path + } + home, err := os.UserHomeDir() + if err != nil { + return path + } + if path == "~" { + return home + } + if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") { + return filepath.Join(home, path[2:]) + } + return path +} + +func validateLocalCommandPath(command string) error { + if !isLocalCommandPath(command) { + return nil + } + + path := expandHomePath(command) + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("local command %q does not exist", command) + } + return fmt.Errorf("failed to stat local command %q: %w", command, err) + } + if info.IsDir() { + return fmt.Errorf("local command %q is a directory", command) + } + if runtime.GOOS != "windows" && info.Mode()&0o111 == 0 { + return fmt.Errorf("local command %q is not executable", command) + } + return nil +} + +func defaultServerProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) (probeResult, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return probeResult{}, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return probeResult{}, fmt.Errorf("server %q did not register a connection", name) + } + + return probeResult{ToolCount: len(conn.Tools)}, nil +} + +func confirmOverwrite(r io.Reader, w io.Writer, name string) (bool, error) { + if _, err := fmt.Fprintf(w, "MCP server %q already exists. Overwrite? [y/N]: ", name); err != nil { + return false, err + } + + var answer string + if _, err := fmt.Fscanln(r, &answer); err != nil { + if errors.Is(err, io.EOF) { + return false, nil + } + return false, err + } + + answer = strings.TrimSpace(strings.ToLower(answer)) + return answer == "y" || answer == "yes", nil +} diff --git a/cmd/picoclaw/internal/mcp/list.go b/cmd/picoclaw/internal/mcp/list.go new file mode 100644 index 000000000..f95fcf65d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/list.go @@ -0,0 +1,78 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" +) + +func newListCommand() *cobra.Command { + var ( + includeStatus bool + timeout time.Duration + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List configured MCP servers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + if len(cfg.Tools.MCP.Servers) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No MCP servers configured.") + return nil + } + + rows := make([]cliui.MCPListRow, 0, len(cfg.Tools.MCP.Servers)) + for _, name := range sortedServerNames(cfg.Tools.MCP.Servers) { + server := cfg.Tools.MCP.Servers[name] + status := "disabled" + if server.Enabled { + status = "enabled" + } + + if includeStatus && server.Enabled { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + result, probeErr := serverProbe(ctx, name, server, cfg.WorkspacePath()) + cancel() + if probeErr != nil { + status = "error" + } else { + status = fmt.Sprintf("ok (%d tools)", result.ToolCount) + } + } + + effectiveDeferred := cfg.Tools.MCP.Discovery.Enabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + + rows = append(rows, cliui.MCPListRow{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Status: status, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + }) + } + + cliui.PrintMCPList(cmd.OutOrStdout(), rows) + return nil + }, + } + + cmd.Flags().BoolVar(&includeStatus, "status", false, "Ping enabled servers and show live status") + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Timeout for each live status check") + + return cmd +} diff --git a/cmd/picoclaw/internal/mcp/remove.go b/cmd/picoclaw/internal/mcp/remove.go new file mode 100644 index 000000000..d82af941d --- /dev/null +++ b/cmd/picoclaw/internal/mcp/remove.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +func newRemoveCommand() *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an MCP server from config", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + if _, exists := cfg.Tools.MCP.Servers[name]; !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + delete(cfg.Tools.MCP.Servers, name) + if len(cfg.Tools.MCP.Servers) == 0 { + cfg.Tools.MCP.Servers = nil + cfg.Tools.MCP.Enabled = false + } + + if err := saveValidatedConfig(cfg); err != nil { + return err + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q removed.\n", name) + return nil + }, + } +} diff --git a/cmd/picoclaw/internal/mcp/show.go b/cmd/picoclaw/internal/mcp/show.go new file mode 100644 index 000000000..65953c2da --- /dev/null +++ b/cmd/picoclaw/internal/mcp/show.go @@ -0,0 +1,237 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" + "github.com/sipeed/picoclaw/pkg/config" + picomcp "github.com/sipeed/picoclaw/pkg/mcp" +) + +type toolDetail struct { + Name string + Description string + Parameters []paramDetail +} + +type paramDetail struct { + Name string + Type string + Description string + Required bool +} + +var serverShowProbe = defaultServerShowProbe + +func defaultServerShowProbe( + ctx context.Context, + name string, + server config.MCPServerConfig, + workspacePath string, +) ([]toolDetail, error) { + mgr := picomcp.NewManager() + defer func() { _ = mgr.Close() }() + + server.Enabled = true + mcpCfg := config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + name: server, + }, + } + + if err := mgr.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil { + return nil, err + } + + conn, ok := mgr.GetServer(name) + if !ok { + return nil, fmt.Errorf("server %q did not register a connection", name) + } + + details := make([]toolDetail, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + details = append(details, toolDetail{ + Name: tool.Name, + Description: tool.Description, + Parameters: extractParameters(tool.InputSchema), + }) + } + return details, nil +} + +func extractParameters(schema any) []paramDetail { + schemaMap := normalizeSchema(schema) + properties, ok := schemaMap["properties"].(map[string]any) + if !ok || len(properties) == 0 { + return nil + } + + required := make(map[string]struct{}) + switch raw := schemaMap["required"].(type) { + case []string: + for _, name := range raw { + required[name] = struct{}{} + } + case []any: + for _, value := range raw { + if name, ok := value.(string); ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]paramDetail, 0, len(names)) + for _, name := range names { + param := paramDetail{Name: name} + if propMap, ok := properties[name].(map[string]any); ok { + if typeName, ok := propMap["type"].(string); ok { + param.Type = strings.TrimSpace(typeName) + } + if desc, ok := propMap["description"].(string); ok { + param.Description = strings.TrimSpace(desc) + } + } + _, param.Required = required[name] + params = append(params, param) + } + return params +} + +func normalizeSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{} + } + if schemaMap, ok := schema.(map[string]any); ok { + return schemaMap + } + + var jsonData []byte + switch raw := schema.(type) { + case json.RawMessage: + jsonData = raw + case []byte: + jsonData = raw + default: + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{} + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{} + } + return result +} + +func newShowCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details and tools for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + serverInfo := buildServerInfo(name, server, cfg.Tools.MCP.Discovery.Enabled) + + if !server.Enabled { + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, nil, true) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + details, err := serverShowProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to connect to MCP server %q: %w", name, err) + } + + tools := make([]cliui.MCPShowTool, 0, len(details)) + for _, d := range details { + params := make([]cliui.MCPShowParam, 0, len(d.Parameters)) + for _, p := range d.Parameters { + params = append(params, cliui.MCPShowParam{ + Name: p.Name, + Type: p.Type, + Description: p.Description, + Required: p.Required, + }) + } + tools = append(tools, cliui.MCPShowTool{ + Name: d.Name, + Description: d.Description, + Parameters: params, + }) + } + + cliui.PrintMCPShow(cmd.OutOrStdout(), serverInfo, tools, false) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 10*time.Second, "Connection timeout") + + return cmd +} + +func buildServerInfo(name string, server config.MCPServerConfig, discoveryEnabled bool) cliui.MCPShowServer { + effectiveDeferred := discoveryEnabled + deferredExplicit := server.Deferred != nil + if deferredExplicit { + effectiveDeferred = *server.Deferred + } + info := cliui.MCPShowServer{ + Name: name, + Type: inferTransportType(server), + Target: renderServerTarget(server), + Enabled: server.Enabled, + EffectiveDeferred: effectiveDeferred, + DeferredExplicit: deferredExplicit, + EnvFile: server.EnvFile, + } + if len(server.Env) > 0 { + keys := make([]string, 0, len(server.Env)) + for k := range server.Env { + keys = append(keys, k) + } + sort.Strings(keys) + info.EnvKeys = keys + } + if len(server.Headers) > 0 { + keys := make([]string, 0, len(server.Headers)) + for k := range server.Headers { + keys = append(keys, k) + } + sort.Strings(keys) + info.Headers = keys + } + return info +} diff --git a/cmd/picoclaw/internal/mcp/test.go b/cmd/picoclaw/internal/mcp/test.go new file mode 100644 index 000000000..101cfee65 --- /dev/null +++ b/cmd/picoclaw/internal/mcp/test.go @@ -0,0 +1,46 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +func newTestCommand() *cobra.Command { + var timeout time.Duration + + cmd := &cobra.Command{ + Use: "test ", + Short: "Test connectivity for a configured MCP server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + name := args[0] + server, exists := cfg.Tools.MCP.Servers[name] + if !exists { + return fmt.Errorf("MCP server %q not found", name) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + result, err := serverProbe(ctx, name, server, cfg.WorkspacePath()) + if err != nil { + return fmt.Errorf("failed to reach MCP server %q: %w", name, err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q reachable (%d tools).\n", name, result.ToolCount) + return nil + }, + } + + cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Second, "Connection timeout") + + return cmd +} diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 0867203a6..abcf03a34 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/mcp" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/model" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" @@ -87,6 +88,7 @@ picoclaw --no-color status`, gateway.NewGatewayCommand(), status.NewStatusCommand(), cron.NewCronCommand(), + mcp.NewMCPCommand(), migrate.NewMigrateCommand(), skills.NewSkillsCommand(), model.NewModelCommand(), diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 309e60ba9..037c7c2e6 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) { "auth", "cron", "gateway", + "mcp", "migrate", "model", "onboard", diff --git a/docs/project/README.it.md b/docs/project/README.it.md index eb2f7c95b..cd7ecac7d 100644 --- a/docs/project/README.it.md +++ b/docs/project/README.it.md @@ -554,7 +554,19 @@ PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connet } ``` -Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). +Puoi gestire i casi MCP più comuni direttamente dalla CLI senza modificare a mano il JSON: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +picoclaw mcp list +picoclaw mcp test filesystem +``` + +`picoclaw mcp` agisce come configuration manager: aggiorna `config.json` sotto `tools.mcp.servers`, ma non mantiene in esecuzione il processo del server. + +Usa `picoclaw mcp edit` quando ti servono campi avanzati come `headers`, `env_file` o `deferred`. + +Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](../reference/tools_configuration.md#mcp-tool). Per la reference della CLI, vedi [MCP Server CLI](../reference/mcp-cli.md). ## ClawdChat Unisciti al Social Network degli Agent @@ -574,6 +586,11 @@ Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singol | `picoclaw status` | Mostra lo stato | | `picoclaw version` | Mostra le info sulla versione | | `picoclaw model` | Visualizza o cambia il modello predefinito | +| `picoclaw mcp list` | Elenca i server MCP configurati | +| `picoclaw mcp add ...` | Aggiunge o aggiorna un server MCP | +| `picoclaw mcp test` | Verifica la raggiungibilità di un server MCP | +| `picoclaw mcp edit` | Apre la config per modifiche MCP avanzate | +| `picoclaw mcp remove` | Rimuove un server MCP dalla config | | `picoclaw cron list` | Elenca tutti i job pianificati | | `picoclaw cron add ...` | Aggiunge un job pianificato | | `picoclaw cron disable` | Disabilita un job pianificato | @@ -600,6 +617,7 @@ Per guide dettagliate oltre questo README: | [Docker & Avvio Rapido](../guides/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent | | [App di Chat](../guides/chat-apps.md) | Tutte le guide di configurazione per 17+ channel | | [Configurazione](../guides/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza | +| [MCP Server CLI](../reference/mcp-cli.md) | Aggiunta, elenco, test, modifica e rimozione dei server MCP da CLI | | [Provider & Modelli](../guides/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list | | [Spawn & Task Asincroni](../guides/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent | | [Hooks](../architecture/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook | diff --git a/docs/reference/README.md b/docs/reference/README.md index eec5c09b4..2e0f53cf7 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -3,6 +3,7 @@ Reference docs for precise configuration, runtime behavior, and tool semantics. - [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills. +- [MCP Server CLI](mcp-cli.md): add, list, test, edit, and remove MCP server entries from the command line. - [Scheduled Tasks and Cron Jobs](cron.md): schedule types, delivery modes, command gates, and storage. - [Config Schema Versioning Guide](config-versioning.md): config schema migration and compatibility notes. - [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers. diff --git a/docs/reference/mcp-cli.md b/docs/reference/mcp-cli.md new file mode 100644 index 000000000..77291b140 --- /dev/null +++ b/docs/reference/mcp-cli.md @@ -0,0 +1,349 @@ +# MCP Server CLI + +> Back to [README](../README.md) + +PicoClaw includes an `mcp` CLI command group for managing MCP server entries in `config.json`. + +This CLI acts as a **configuration manager**: + +- it adds, updates, removes, and validates entries under `tools.mcp.servers` +- it does **not** keep MCP servers running itself +- the gateway / host still starts the configured servers when MCP is enabled + +## Where It Writes + +The CLI updates the same config file used by the rest of PicoClaw: + +- `PICOCLAW_CONFIG` if set +- otherwise `~/.picoclaw/config.json` + +When the CLI writes the file, it: + +- saves atomically +- preserves the standard 2-space JSON formatting used by PicoClaw +- validates the generated JSON before writing + +Behavior notes: + +- `picoclaw mcp add ...` enables `tools.mcp.enabled` +- removing the last server with `picoclaw mcp remove ...` disables `tools.mcp.enabled` + +## Quick Start + +Add a stdio server via `npx`: + +```bash +picoclaw mcp add filesystem -- npx -y @modelcontextprotocol/server-filesystem /tmp +``` + +Add a stdio server with environment variables: + +```bash +picoclaw mcp add github --env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx -- npx -y @modelcontextprotocol/server-github +``` + +Add a remote HTTP server: + +```bash +picoclaw mcp add context7 --transport http https://mcp.context7.com/mcp +``` + +Add a remote HTTP server with auth header, even with flags after the URL: + +```bash +picoclaw mcp add apify "https://mcp.apify.com/" -t http --header "Authorization: Bearer OMITTED" +``` + +Add a stdio server using an explicit command separator: + +```bash +picoclaw mcp add --transport stdio --env AIRTABLE_API_KEY=YOUR_KEY airtable -- npx -y airtable-mcp-server +``` + +Inspect the configured entries: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +Inspect one server's full details and its exposed tools: + +```bash +picoclaw mcp show filesystem +``` + +Probe a single server entry: + +```bash +picoclaw mcp test filesystem +``` + +Open the raw config for advanced editing: + +```bash +picoclaw mcp edit +``` + +## Command Summary + +| Command | Purpose | +|---------|---------| +| `picoclaw mcp add [flags] [args...]` | Add or update an MCP server entry | +| `picoclaw mcp remove ` | Remove a server entry from config | +| `picoclaw mcp list` | List configured MCP servers | +| `picoclaw mcp show ` | Show full details and tools for one server | +| `picoclaw mcp test ` | Try connecting to one configured server | +| `picoclaw mcp edit` | Open `config.json` in `$EDITOR` | + +## `picoclaw mcp add` + +Syntax: + +```bash +picoclaw mcp add [flags] [args...] +``` + +Supported flags: + +| Flag | Meaning | +|------|---------| +| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. | +| `--header`, `-H` | Add an HTTP header in `Name: Value` or `Name=Value` format. Repeatable. | +| `--transport`, `-t` | Transport type: `stdio` (default), `http`, or `sse`. | +| `--force`, `-f` | Overwrite an existing server entry without confirmation. | +| `--deferred` | Mark the server as deferred: tools are hidden and discoverable on demand. | +| `--no-deferred` | Mark the server as non-deferred: tools are always loaded into context. | + +When neither `--deferred` nor `--no-deferred` is passed, the `deferred` field is omitted from the stored config and the global `discovery.enabled` value applies at runtime. + +Supported forms: + +```bash +picoclaw mcp add [flags] [args...] +picoclaw mcp add [flags] -- [args...] +``` + +Parsing behavior: + +- CLI flags can appear before the name, between the name and target, or after the URL for remote transports +- for `stdio`, the most robust form is `-- [args...]` +- use the `--` separator when the stdio command itself has arguments that may look like PicoClaw CLI flags +- without `--`, PicoClaw treats the first two non-flag tokens as `` and `` + +Example: + +```bash +picoclaw mcp add sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +This stores: + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "servers": { + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"] + } + } + } + } +} +``` + +Adding the same server with `--deferred` stores the extra field: + +```bash +picoclaw mcp add --deferred sqlite npx -y @modelcontextprotocol/server-sqlite --db ./mydb.db +``` + +```json +{ + "sqlite": { + "enabled": true, + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sqlite", "--db", "./mydb.db"], + "deferred": true + } +} +``` + +### Add Command Rules + +For `stdio`: + +- `` is treated as the command +- `[args...]` are stored in `args` +- `--env` is supported +- `--header` is rejected +- `-- [args...]` is supported and recommended for unambiguous parsing + +For `http` / `sse`: + +- `` must be a valid URL +- extra command args are rejected +- `--env` is rejected +- `--header` is supported and stored in `headers` + +Overwrite behavior: + +- if `` already exists, PicoClaw asks for confirmation +- use `--force` to skip the prompt + +Local path validation: + +- if the command looks like a local path such as `./server.py` or `/opt/mcp/server` +- PicoClaw checks that the file exists +- on non-Windows platforms, it also checks that the file is executable + +Clear URL/transport error: + +- if the target looks like `https://...` but transport is still `stdio`, PicoClaw returns an explicit error telling you to use `--transport http` or `--transport sse` + +## `picoclaw mcp remove` + +Syntax: + +```bash +picoclaw mcp remove +``` + +This removes the named entry from `tools.mcp.servers`. + +If the removed server was the last configured MCP server, PicoClaw also disables `tools.mcp.enabled`. + +## `picoclaw mcp list` + +Syntax: + +```bash +picoclaw mcp list +picoclaw mcp list --status +``` + +On wide terminals the output is a styled box (same look as `mcp show`). On narrow terminals or when stdout is not a TTY, a plain ASCII table is printed instead. + +Output fields: + +| Field | Meaning | +|-------|---------| +| `Name` | Server key inside `tools.mcp.servers` | +| `Type` | Effective transport: `stdio`, `http`, or `sse` | +| `Command` / `Target` | Stored command line for stdio servers, or URL for remote servers | +| `Status` | `enabled` / `disabled` by default; with `--status`: `ok (N tools)` or `error` | +| `Deferred` | `deferred` if the per-server override is `true`; `eager` if `false`; omitted if not set | + +Notes: + +- without `--status`, PicoClaw prints configuration state only +- with `--status`, PicoClaw tries to connect to each enabled server and reports `ok (N tools)` or `error` +- to see the full list of tools a server exposes, use `picoclaw mcp show ` + +## `picoclaw mcp show` + +Syntax: + +```bash +picoclaw mcp show +picoclaw mcp show --timeout 15s +``` + +This connects to the named server and prints: + +- server metadata: name, transport type, target, enabled state, deferred override, env var names, env file, header names +- every tool the server exposes, with its name, description, and parameters (name, type, required/optional, description) + +On wide terminals the output is a styled box matching the `mcp list` look. On narrow terminals or non-TTY stdout, plain text is printed instead. + +Example output (wide terminal): + +``` +╭──────────────────────────────────────────────────────────╮ +│ ⬡ filesystem │ +│ │ +│ Type stdio │ +│ Target npx -y @modelcontextprotocol/server-fs /tmp │ +│ Enabled yes │ +│ Deferred no │ +│ │ +│ Tools (3) │ +│ │ +│ read_file [1/3] │ +│ Read the complete contents of a file from the disk │ +│ │ +│ path required │ +│ Path to the file to read │ +│ ──────────────────────────────────────────────────────── │ +│ ... │ +╰──────────────────────────────────────────────────────────╯ +``` + +Flags: + +| Flag | Default | Meaning | +|------|---------|---------| +| `--timeout` | `10s` | Connection timeout | + +Notes: + +- if the server is disabled in config, `mcp show` prints the metadata only and skips tool discovery +- `mcp show` always connects live to fetch the tool list; use `mcp test` if you only need a reachability check + +## `picoclaw mcp test` + +Syntax: + +```bash +picoclaw mcp test +``` + +This performs a direct connection test for one configured entry and prints the number of discovered tools when successful. + +It is useful when: + +- you want to verify a newly added server before starting the gateway +- you want to debug one server without probing the whole list +- the entry is currently disabled in config but you still want to validate its definition + +## `picoclaw mcp edit` + +Syntax: + +```bash +picoclaw mcp edit +``` + +This opens the config file in the editor pointed to by `$EDITOR`. + +Use it when you need to configure MCP fields that are not exposed directly by `picoclaw mcp add`, such as: + +- `env_file` + +If `$EDITOR` is not set, the command fails with an explicit error. + +## Recommended Workflow + +For common cases: + +1. Add the server with `picoclaw mcp add` (include `--deferred` if you want tools hidden by default). +2. Verify connectivity and inspect the exposed tools with `picoclaw mcp show `. +3. Check all servers at a glance with `picoclaw mcp list --status`. +4. Start PicoClaw normally so the configured MCP server is loaded by the host. + +For advanced cases (e.g. `env_file`): + +1. Add the base entry with `picoclaw mcp add`. +2. Run `picoclaw mcp edit` to fill in `env_file` or other fields not exposed as CLI flags. +3. Run `picoclaw mcp show ` to confirm the final configuration and tool list. + +## Related Docs + +- [Tools Configuration](tools_configuration.md#mcp-tool): MCP config structure, transports, discovery, and examples +- [README](../README.md): high-level overview diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md index fa33f0bb4..810d91ef2 100644 --- a/docs/reference/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -258,6 +258,17 @@ For schedule types, execution modes (`deliver`, agent turn, and command jobs), p The MCP tool enables integration with external Model Context Protocol servers. +If you prefer not to edit JSON manually, PicoClaw also provides an MCP configuration manager CLI: + +- `picoclaw mcp add` — add or update a server (supports `--deferred` / `--no-deferred`) +- `picoclaw mcp list` — list all configured servers with status and deferred state +- `picoclaw mcp show ` — show full details and the tool list for one server +- `picoclaw mcp test ` — connectivity check for one server +- `picoclaw mcp remove ` — remove a server entry +- `picoclaw mcp edit` — open `config.json` in `$EDITOR` for advanced edits + +These commands manage the same `tools.mcp.servers` section documented below. See [MCP Server CLI](mcp-cli.md) for command syntax, examples, and behavior details. + ### Tool Discovery (Lazy Loading) When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window