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).
##
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).
##
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