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:
commit
8d51d306b3
19 changed files with 2541 additions and 3 deletions
21
README.md
21
README.md
|
|
@ -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
|
## <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 status` | Show status |
|
||||||
| `picoclaw version` | Show version info |
|
| `picoclaw version` | Show version info |
|
||||||
| `picoclaw model` | View or switch the default model |
|
| `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 list` | List all scheduled jobs |
|
||||||
| `picoclaw cron add ...` | Add a scheduled job |
|
| `picoclaw cron add ...` | Add a scheduled job |
|
||||||
| `picoclaw cron disable` | Disable 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 |
|
| [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 |
|
| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
|
||||||
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [Spawn & Async Tasks](docs/guides/spawn-tasks.md) | Quick tasks, long tasks with spawn, async sub-agent orchestration |
|
||||||
|
|
|
||||||
384
cmd/picoclaw/internal/cliui/mcp_show.go
Normal file
384
cmd/picoclaw/internal/cliui/mcp_show.go
Normal 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
cmd/picoclaw/internal/mcp/add.go
Normal file
249
cmd/picoclaw/internal/mcp/add.go
Normal 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
cmd/picoclaw/internal/mcp/command.go
Normal file
25
cmd/picoclaw/internal/mcp/command.go
Normal 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
cmd/picoclaw/internal/mcp/command_test.go
Normal file
619
cmd/picoclaw/internal/mcp/command_test.go
Normal 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
cmd/picoclaw/internal/mcp/edit.go
Normal file
54
cmd/picoclaw/internal/mcp/edit.go
Normal 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
cmd/picoclaw/internal/mcp/helpers.go
Normal file
359
cmd/picoclaw/internal/mcp/helpers.go
Normal 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
cmd/picoclaw/internal/mcp/list.go
Normal file
78
cmd/picoclaw/internal/mcp/list.go
Normal 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
cmd/picoclaw/internal/mcp/remove.go
Normal file
39
cmd/picoclaw/internal/mcp/remove.go
Normal 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
cmd/picoclaw/internal/mcp/show.go
Normal file
237
cmd/picoclaw/internal/mcp/show.go
Normal 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
cmd/picoclaw/internal/mcp/test.go
Normal file
46
cmd/picoclaw/internal/mcp/test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
"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/migrate"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
|
||||||
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
||||||
|
|
@ -87,6 +88,7 @@ picoclaw --no-color status`,
|
||||||
gateway.NewGatewayCommand(),
|
gateway.NewGatewayCommand(),
|
||||||
status.NewStatusCommand(),
|
status.NewStatusCommand(),
|
||||||
cron.NewCronCommand(),
|
cron.NewCronCommand(),
|
||||||
|
mcp.NewMCPCommand(),
|
||||||
migrate.NewMigrateCommand(),
|
migrate.NewMigrateCommand(),
|
||||||
skills.NewSkillsCommand(),
|
skills.NewSkillsCommand(),
|
||||||
model.NewModelCommand(),
|
model.NewModelCommand(),
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ func TestNewPicoclawCommand(t *testing.T) {
|
||||||
"auth",
|
"auth",
|
||||||
"cron",
|
"cron",
|
||||||
"gateway",
|
"gateway",
|
||||||
|
"mcp",
|
||||||
"migrate",
|
"migrate",
|
||||||
"model",
|
"model",
|
||||||
"onboard",
|
"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
|
## <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 status` | Mostra lo stato |
|
||||||
| `picoclaw version` | Mostra le info sulla versione |
|
| `picoclaw version` | Mostra le info sulla versione |
|
||||||
| `picoclaw model` | Visualizza o cambia il modello predefinito |
|
| `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 list` | Elenca tutti i job pianificati |
|
||||||
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
| `picoclaw cron add ...` | Aggiunge un job pianificato |
|
||||||
| `picoclaw cron disable` | Disabilita 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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.
|
Reference docs for precise configuration, runtime behavior, and tool semantics.
|
||||||
|
|
||||||
- [Tools Configuration](tools_configuration.md): per-tool configuration, execution policies, MCP, and Skills.
|
- [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.
|
- [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.
|
- [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.
|
- [Dynamic Rate Limiting](rate-limiting.md): request throttling behavior for LLM providers.
|
||||||
|
|
|
||||||
361
docs/reference/mcp-cli.md
Normal file
361
docs/reference/mcp-cli.md
Normal 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
|
||||||
|
|
@ -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.
|
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)
|
### Tool Discovery (Lazy Loading)
|
||||||
|
|
||||||
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
|
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,24 @@ type headerTransport struct {
|
||||||
headers map[string]string
|
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) {
|
func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
// Clone the request to avoid modifying the original
|
// Clone the request to avoid modifying the original
|
||||||
req = req.Clone(req.Context())
|
req = req.Clone(req.Context())
|
||||||
|
|
@ -324,7 +342,7 @@ func (m *Manager) ConnectServer(
|
||||||
"command": cfg.Command,
|
"command": cfg.Command,
|
||||||
})
|
})
|
||||||
// Create command with context
|
// 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
|
// Build environment variables with proper override semantics
|
||||||
// Use a map to ensure config variables override file variables
|
// 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) {
|
func TestEnvFilePriority(t *testing.T) {
|
||||||
// Create a temporary .env file
|
// Create a temporary .env file
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue