Merge pull request #2641 from afjcjsbx/feat/mcp-cli

feat(mcp): add show, add, list, remove, test, edit cli commands
This commit is contained in:
Mauro
2026-04-24 13:06:34 +02:00
committed by GitHub
19 changed files with 2541 additions and 3 deletions
+20 -1
View File
@@ -571,7 +571,20 @@ 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 that are not covered by `picoclaw mcp add`.
For example, `picoclaw mcp add` supports `--deferred` and `--env-file`, while `picoclaw mcp edit` is still useful for direct JSON editing and uncommon MCP settings.
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).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Join the Agent Social Network
@@ -591,6 +604,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 +637,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 |
+384
View File
@@ -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 + "…"
}
+249
View File
@@ -0,0 +1,249 @@
package mcp
import (
"fmt"
"net/url"
"strings"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/config"
)
type addOptions struct {
Env []string
EnvFile string
Headers []string
Transport string
Force bool
Deferred *bool // nil = not set, true = deferred, false = not deferred
}
func newAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add [flags] <name> <command-or-url> [args...]",
Short: "Add or update an MCP server",
DisableFlagParsing: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts, name, target, targetArgs, showHelp, err := parseAddArgs(args)
if showHelp {
return cmd.Help()
}
if err != nil {
return err
}
cfg, err := loadConfig()
if err != nil {
return err
}
if cfg.Tools.MCP.Servers == nil {
cfg.Tools.MCP.Servers = make(map[string]config.MCPServerConfig)
}
if _, exists := cfg.Tools.MCP.Servers[name]; exists && !opts.Force {
var overwrite bool
overwrite, err = confirmOverwrite(cmd.InOrStdin(), cmd.OutOrStdout(), name)
if err != nil {
return fmt.Errorf("failed to confirm overwrite: %w", err)
}
if !overwrite {
return fmt.Errorf("aborted: MCP server %q already exists", name)
}
}
server, err := buildServerConfig(target, targetArgs, opts)
if err != nil {
return err
}
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.Servers[name] = server
if err := saveValidatedConfig(cfg); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "✓ MCP server %q saved.\n", name)
return nil
},
}
flags := cmd.Flags()
flags.StringArrayP("env", "e", nil, "Environment variable in KEY=value format (repeatable, saved to config)")
flags.String("env-file", "", "Path to an env file for stdio servers (recommended for secrets)")
flags.StringArrayP("header", "H", nil, "HTTP header in 'Name: Value' or 'Name=Value' format (repeatable)")
flags.StringP("transport", "t", "stdio", "Transport type: stdio, http, or sse")
flags.BoolP("force", "f", false, "Overwrite an existing server without prompting")
flags.Bool("deferred", false, "Mark server as deferred (tools hidden until explicitly activated)")
flags.Bool("no-deferred", false, "Mark server as non-deferred (tools always active)")
return cmd
}
func parseAddArgs(args []string) (addOptions, string, string, []string, bool, error) {
opts := addOptions{Transport: "stdio"}
var positional []string
serverArgs := make([]string, 0)
explicitCommand := make([]string, 0)
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--help" || arg == "-h":
return addOptions{}, "", "", nil, true, nil
case arg == "--":
if i+1 < len(args) {
explicitCommand = append(explicitCommand, args[i+1:]...)
}
i = len(args)
case arg == "--force" || arg == "-f":
opts.Force = true
case arg == "--deferred":
t := true
opts.Deferred = &t
case arg == "--no-deferred":
f := false
opts.Deferred = &f
case arg == "--transport" || arg == "-t":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Transport = args[i]
case strings.HasPrefix(arg, "--transport="):
opts.Transport = strings.TrimPrefix(arg, "--transport=")
case arg == "--env" || arg == "-e":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Env = append(opts.Env, args[i])
case arg == "--env-file":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.EnvFile = args[i]
case strings.HasPrefix(arg, "--env="):
opts.Env = append(opts.Env, strings.TrimPrefix(arg, "--env="))
case strings.HasPrefix(arg, "--env-file="):
opts.EnvFile = strings.TrimPrefix(arg, "--env-file=")
case arg == "--header" || arg == "-H":
if i+1 >= len(args) {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing value for %s", arg)
}
i++
opts.Headers = append(opts.Headers, args[i])
case strings.HasPrefix(arg, "--header="):
opts.Headers = append(opts.Headers, strings.TrimPrefix(arg, "--header="))
case strings.HasPrefix(arg, "-") && len(positional) >= 2:
serverArgs = append(serverArgs, args[i:]...)
i = len(args)
default:
positional = append(positional, arg)
}
}
if len(explicitCommand) > 0 {
if len(positional) != 1 {
return addOptions{}, "", "", nil, false, fmt.Errorf(
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
)
}
if len(explicitCommand) == 0 {
return addOptions{}, "", "", nil, false, fmt.Errorf("missing stdio command after --")
}
return opts, positional[0], explicitCommand[0], explicitCommand[1:], false, nil
}
if len(positional) < 2 {
return addOptions{}, "", "", nil, false, fmt.Errorf(
"usage: picoclaw mcp add [flags] <name> <command-or-url> [args...] or picoclaw mcp add [flags] <name> -- <command> [args...]",
)
}
targetArgs := make([]string, 0, len(positional)-2+len(serverArgs))
targetArgs = append(targetArgs, positional[2:]...)
targetArgs = append(targetArgs, serverArgs...)
return opts, positional[0], positional[1], targetArgs, false, nil
}
func buildServerConfig(target string, args []string, opts addOptions) (config.MCPServerConfig, error) {
transport := strings.ToLower(strings.TrimSpace(opts.Transport))
if transport == "" {
transport = "stdio"
}
switch transport {
case "stdio", "http", "sse":
default:
return config.MCPServerConfig{}, fmt.Errorf("unsupported transport %q", opts.Transport)
}
env, err := parseEnvAssignments(opts.Env)
if err != nil {
return config.MCPServerConfig{}, err
}
headers, err := parseHeaderAssignments(opts.Headers)
if err != nil {
return config.MCPServerConfig{}, err
}
server := config.MCPServerConfig{
Enabled: true,
Type: transport,
Deferred: opts.Deferred,
}
switch transport {
case "http", "sse":
if len(env) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("--env can only be used with stdio transport")
}
if strings.TrimSpace(opts.EnvFile) != "" {
return config.MCPServerConfig{}, fmt.Errorf("--env-file can only be used with stdio transport")
}
if len(args) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("%s transport does not accept command arguments", transport)
}
parsedURL, err := url.ParseRequestURI(target)
if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
return config.MCPServerConfig{}, fmt.Errorf("invalid MCP URL %q", target)
}
server.URL = target
server.Headers = headers
return server, nil
}
if len(headers) > 0 {
return config.MCPServerConfig{}, fmt.Errorf("--header can only be used with http or sse transport")
}
if looksLikeRemoteURL(target) {
return config.MCPServerConfig{}, fmt.Errorf(
"target %q looks like a remote MCP URL, but transport is %q. Use --transport http or --transport sse",
target,
transport,
)
}
command := target
commandArgs := append([]string(nil), args...)
if err := validateLocalCommandPath(target); err != nil {
return config.MCPServerConfig{}, err
}
if isLocalCommandPath(command) {
command = expandHomePath(command)
}
server.Command = command
server.Args = commandArgs
server.Env = env
server.EnvFile = strings.TrimSpace(opts.EnvFile)
return server, nil
}
+25
View File
@@ -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
}
+619
View File
@@ -0,0 +1,619 @@
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 TestMCPAddSupportsEnvFileForStdio(t *testing.T) {
configPath := setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--env-file",
".env.mcp",
"filesystem",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["filesystem"]
assert.Equal(t, "stdio", server.Type)
assert.Equal(t, "npx", server.Command)
assert.Equal(t, []string{"-y", "@modelcontextprotocol/server-filesystem"}, server.Args)
assert.Equal(t, ".env.mcp", server.EnvFile)
}
func TestMCPAddRejectsEnvFileForHTTP(t *testing.T) {
setupMCPConfigEnv(t)
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{
"add",
"--transport",
"http",
"--env-file",
".env.mcp",
"context7",
"https://mcp.context7.com/mcp",
}, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "--env-file can only be used with stdio transport")
}
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 TestMCPAddExpandsHomeInSavedLocalCommand(t *testing.T) {
configPath := setupMCPConfigEnv(t)
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
localCmd := filepath.Join(homeDir, "bin", "my-mcp")
require.NoError(t, os.MkdirAll(filepath.Dir(localCmd), 0o755))
require.NoError(t, os.WriteFile(localCmd, []byte("#!/bin/sh\nexit 0\n"), 0o755))
tildeCmd := "~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp")
cmd := NewMCPCommand()
_, err := executeCommand(cmd, []string{"add", "local-home", tildeCmd}, "")
require.NoError(t, err)
cfg := readMCPConfig(t, configPath)
server := cfg.Tools.MCP.Servers["local-home"]
assert.Equal(t, localCmd, server.Command)
}
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
}
+54
View File
@@ -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
},
}
}
+359
View File
@@ -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 "<missing url>"
}
return server.URL
}
parts := append([]string{server.Command}, server.Args...)
rendered := strings.TrimSpace(strings.Join(parts, " "))
if rendered == "" {
return "<missing command>"
}
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
}
+78
View File
@@ -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
}
+39
View File
@@ -0,0 +1,39 @@
package mcp
import (
"fmt"
"github.com/spf13/cobra"
)
func newRemoveCommand() *cobra.Command {
return &cobra.Command{
Use: "remove <name>",
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
},
}
}
+237
View File
@@ -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 <name>",
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
}
+46
View File
@@ -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 <name>",
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
}
+2
View File
@@ -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(),
+1
View File
@@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"auth",
"cron",
"gateway",
"mcp",
"migrate",
"model",
"onboard",
+20 -1
View File
@@ -554,7 +554,20 @@ 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 che non sono coperti da `picoclaw mcp add`.
Per esempio, `picoclaw mcp add` supporta `--deferred` e `--env-file`, mentre `picoclaw mcp edit` resta utile per modifiche JSON dirette e opzioni MCP meno comuni.
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).
## <img src="../../assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
@@ -574,6 +587,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 +618,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 |
+1
View File
@@ -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.
+361
View File
@@ -0,0 +1,361 @@
# 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 saved in config:
```bash
picoclaw mcp add github --env GITHUB_PERSONAL_ACCESS_TOKEN=ghp_xxx -- npx -y @modelcontextprotocol/server-github
```
Add a stdio server using an env file for secrets:
```bash
picoclaw mcp add github --env-file .env.github -- 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 <name> [flags] <command-or-url> [args...]` | Add or update an MCP server entry |
| `picoclaw mcp remove <name>` | Remove a server entry from config |
| `picoclaw mcp list` | List configured MCP servers |
| `picoclaw mcp show <name>` | Show full details and tools for one server |
| `picoclaw mcp test <name>` | Try connecting to one configured server |
| `picoclaw mcp edit` | Open `config.json` in `$EDITOR` |
## `picoclaw mcp add`
Syntax:
```bash
picoclaw mcp add <name> [flags] <command-or-url> [args...]
```
Supported flags:
| Flag | Meaning |
|------|---------|
| `--env`, `-e` | Add a stdio environment variable in `KEY=value` format. Repeatable. Values are saved to config. |
| `--env-file` | Attach an env file path to a stdio server. Recommended for secrets you do not want stored inline in `config.json`. |
| `--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] <name> <command-or-url> [args...]
picoclaw mcp add [flags] <name> -- <command> [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 `-- <command> [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 `<name>` and `<command-or-url>`
Secret handling:
- `--env KEY=value` stores the resolved value directly in `config.json`
- use `--env-file` instead when the value is sensitive and should stay outside the main config file
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`:
- `<command-or-url>` is treated as the command
- `[args...]` are stored in `args`
- `--env` is supported
- `--env-file` is supported and stored in `env_file`
- `--header` is rejected
- `-- <command> [args...]` is supported and recommended for unambiguous parsing
For `http` / `sse`:
- `<command-or-url>` must be a valid URL
- extra command args are rejected
- `--env` is rejected
- `--env-file` is rejected
- `--header` is supported and stored in `headers`
Overwrite behavior:
- if `<name>` 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 <name>
```
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 <name>`
## `picoclaw mcp show`
Syntax:
```bash
picoclaw mcp show <name>
picoclaw mcp show <name> --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 <string> 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 <name>
```
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`.
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 <name>`.
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:
1. Add the base entry with `picoclaw mcp add`.
2. Run `picoclaw mcp edit` to fill in fields that are not exposed as CLI flags.
3. Run `picoclaw mcp show <name>` 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
+11
View File
@@ -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 <name>` — show full details and the tool list for one server
- `picoclaw mcp test <name>` — connectivity check for one server
- `picoclaw mcp remove <name>` — 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
+19 -1
View File
@@ -25,6 +25,24 @@ type headerTransport struct {
headers map[string]string
}
func expandHomeCommandPath(command string) string {
if command == "" || command[0] != '~' {
return command
}
home, err := os.UserHomeDir()
if err != nil {
return command
}
if command == "~" {
return home
}
if strings.HasPrefix(command, "~/") || strings.HasPrefix(command, "~\\") {
return filepath.Join(home, command[2:])
}
return command
}
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
req = req.Clone(req.Context())
@@ -324,7 +342,7 @@ func (m *Manager) ConnectServer(
"command": cfg.Command,
})
// Create command with context
cmd := exec.CommandContext(ctx, cfg.Command, cfg.Args...)
cmd := exec.CommandContext(ctx, expandHomeCommandPath(cfg.Command), cfg.Args...)
// Build environment variables with proper override semantics
// Use a map to ensure config variables override file variables
+16
View File
@@ -136,6 +136,22 @@ func TestLoadEnvFileNotFound(t *testing.T) {
}
}
func TestExpandHomeCommandPath(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("USERPROFILE", homeDir)
want := filepath.Join(homeDir, "bin", "my-mcp")
got := expandHomeCommandPath("~" + string(os.PathSeparator) + filepath.Join("bin", "my-mcp"))
if got != want {
t.Fatalf("expandHomeCommandPath() = %q, want %q", got, want)
}
if got := expandHomeCommandPath("npx"); got != "npx" {
t.Fatalf("expandHomeCommandPath() should leave bare commands unchanged, got %q", got)
}
}
func TestEnvFilePriority(t *testing.T) {
// Create a temporary .env file
tmpDir := t.TempDir()