mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
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:
@@ -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 |
|
||||
|
||||
@@ -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 + "…"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||
"auth",
|
||||
"cron",
|
||||
"gateway",
|
||||
"mcp",
|
||||
"migrate",
|
||||
"model",
|
||||
"onboard",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user