From 276f5425f0b8ac3e56891708ecda94625b4cd3c4 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Wed, 15 Apr 2026 19:38:30 +0200 Subject: [PATCH] feat(commands): add MCP slash commands and tool details --- docs/channels/telegram/README.md | 4 + docs/guides/chat-apps.md | 4 +- docs/guides/configuration.md | 6 +- pkg/agent/agent_command.go | 208 +++++++++++++++++++++++++++++++ pkg/agent/agent_mcp.go | 6 + pkg/agent/agent_test.go | 69 ++++++++++ pkg/commands/builtin_test.go | 88 ++++++++++++- pkg/commands/cmd_list.go | 5 + pkg/commands/cmd_show.go | 6 + pkg/commands/handler_mcp.go | 106 ++++++++++++++++ pkg/commands/runtime.go | 23 ++++ 11 files changed, 521 insertions(+), 4 deletions(-) create mode 100644 pkg/commands/handler_mcp.go diff --git a/docs/channels/telegram/README.md b/docs/channels/telegram/README.md index 3b114ebef..a4138009e 100644 --- a/docs/channels/telegram/README.md +++ b/docs/channels/telegram/README.md @@ -44,6 +44,8 @@ Telegram auto-registers PicoClaw's top-level bot commands at startup, including Skill-related commands: - `/list skills` lists the installed skills visible to the current agent. +- `/list mcp` lists configured MCP servers and whether they are deferred/connected. +- `/show mcp ` lists the active tools for a connected MCP server. - `/use ` forces a skill for a single request. - `/use ` arms the skill for your next message in the same chat. - `/use clear` clears a pending skill override. @@ -52,6 +54,8 @@ Examples: ```text /list skills +/list mcp +/show mcp github /use git explain how to squash the last 3 commits /use git explain how to squash the last 3 commits diff --git a/docs/guides/chat-apps.md b/docs/guides/chat-apps.md index 140a659d1..62418f91a 100644 --- a/docs/guides/chat-apps.md +++ b/docs/guides/chat-apps.md @@ -67,9 +67,11 @@ Telegram command menu registration remains channel-local discovery UX; generic c If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background. -You can also manage installed skills directly from Telegram: +You can also inspect skills and MCP servers directly from Telegram: - `/list skills` +- `/list mcp` +- `/show mcp ` - `/use ` - `/use ` and then send the actual request in the next message - `/use clear` diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index ef3b14b24..826fa681d 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -97,9 +97,11 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills ### Using Skills From Chat Channels -Once skills are installed, you can inspect and force them directly from a chat channel: +Once skills are installed, and MCP servers are configured, you can inspect and force them directly from a chat channel: - `/list skills` shows the installed skill names available to the current agent. +- `/list mcp` shows configured MCP servers with enabled/deferred/connected status. +- `/show mcp ` shows the active tools exposed by a connected MCP server. - `/use ` forces a specific skill for a single request. - `/use ` arms that skill for your next message in the same chat session. - `/use clear` cancels a pending skill override created by `/use `. @@ -109,6 +111,8 @@ Examples: ```text /list skills +/list mcp +/show mcp github /use git explain how to squash the last 3 commits /btw remind me what we already decided about the deploy plan /use italiapersonalfinance diff --git a/pkg/agent/agent_command.go b/pkg/agent/agent_command.go index 277ef77cd..a2ed068d6 100644 --- a/pkg/agent/agent_command.go +++ b/pkg/agent/agent_command.go @@ -4,11 +4,15 @@ package agent import ( "context" + "encoding/json" "fmt" + "sort" "strings" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/commands" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -133,6 +137,120 @@ func (al *AgentLoop) buildCommandsRuntime( Config: cfg, ListAgentIDs: registry.ListAgentIDs, ListDefinitions: al.cmdRegistry.Definitions, + ListMCPServers: func(ctx context.Context) []commands.MCPServerInfo { + if cfg == nil { + return nil + } + + if len(cfg.Tools.MCP.Servers) == 0 { + return nil + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to refresh MCP status for command", + map[string]any{ + "error": err.Error(), + }) + } + + connected := make(map[string]int) + if manager := al.mcp.getManager(); manager != nil { + for serverName, conn := range manager.GetServers() { + connected[serverName] = len(conn.Tools) + } + } + + servers := make([]commands.MCPServerInfo, 0, len(cfg.Tools.MCP.Servers)) + for serverName, serverCfg := range cfg.Tools.MCP.Servers { + toolCount, isConnected := connected[serverName] + servers = append(servers, commands.MCPServerInfo{ + Name: serverName, + Enabled: serverCfg.Enabled, + Deferred: serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg), + Connected: isConnected, + ToolCount: toolCount, + }) + } + + sort.Slice(servers, func(i, j int) bool { + return strings.ToLower(servers[i].Name) < strings.ToLower(servers[j].Name) + }) + + return servers + }, + ListMCPTools: func(ctx context.Context, serverName string) ([]commands.MCPToolInfo, error) { + if cfg == nil { + return nil, fmt.Errorf("command unavailable: config not loaded") + } + + serverName = strings.TrimSpace(serverName) + if serverName == "" { + return nil, fmt.Errorf("server name is required") + } + + resolvedName := "" + var serverCfg config.MCPServerConfig + for name, candidate := range cfg.Tools.MCP.Servers { + if strings.EqualFold(name, serverName) { + resolvedName = name + serverCfg = candidate + break + } + } + if resolvedName == "" { + return nil, fmt.Errorf("MCP server '%s' is not configured", serverName) + } + if !serverCfg.Enabled { + return nil, fmt.Errorf("MCP server '%s' is configured but disabled", resolvedName) + } + if !cfg.Tools.IsToolEnabled("mcp") { + return nil, fmt.Errorf("MCP integration is disabled") + } + + if err := al.ensureMCPInitialized(ctx); err != nil { + logger.WarnCF("agent", "Failed to initialize MCP runtime for command", + map[string]any{ + "server": resolvedName, + "error": err.Error(), + }) + } + + manager := al.mcp.getManager() + if manager == nil { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + conn, ok := manager.GetServer(resolvedName) + if !ok { + return nil, fmt.Errorf("MCP server '%s' is configured but not connected", resolvedName) + } + + toolInfos := make([]commands.MCPToolInfo, 0, len(conn.Tools)) + for _, tool := range conn.Tools { + if tool == nil { + continue + } + name := strings.TrimSpace(tool.Name) + if name == "" { + continue + } + + description := strings.TrimSpace(tool.Description) + if description == "" { + description = fmt.Sprintf("MCP tool from %s server", resolvedName) + } + + toolInfos = append(toolInfos, commands.MCPToolInfo{ + Name: name, + Description: description, + Parameters: summarizeMCPToolParameters(tool.InputSchema), + }) + } + sort.Slice(toolInfos, func(i, j int) bool { + return toolInfos[i].Name < toolInfos[j].Name + }) + return toolInfos, nil + }, GetEnabledChannels: func() []string { if al.channelManager == nil { return nil @@ -236,6 +354,96 @@ func (al *AgentLoop) buildCommandsRuntime( return rt } +func summarizeMCPToolParameters(schema any) []commands.MCPToolParameterInfo { + schemaMap := normalizeMCPSchema(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 { + name, ok := value.(string) + if ok { + required[name] = struct{}{} + } + } + } + + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + + params := make([]commands.MCPToolParameterInfo, 0, len(names)) + for _, name := range names { + param := commands.MCPToolParameterInfo{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 normalizeMCPSchema(schema any) map[string]any { + if schema == nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + 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 + } + + if jsonData == nil { + var err error + jsonData, err = json.Marshal(schema) + if err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + } + + var result map[string]any + if err := json.Unmarshal(jsonData, &result); err != nil { + return map[string]any{ + "type": "object", + "properties": map[string]any{}, + "required": []string{}, + } + } + + return result +} + func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) { sessionKey = strings.TrimSpace(sessionKey) if sessionKey == "" || len(skillNames) == 0 { diff --git a/pkg/agent/agent_mcp.go b/pkg/agent/agent_mcp.go index 21b6b9eb2..9a2e3d536 100644 --- a/pkg/agent/agent_mcp.go +++ b/pkg/agent/agent_mcp.go @@ -67,6 +67,12 @@ func (r *mcpRuntime) hasManager() bool { return r.manager != nil } +func (r *mcpRuntime) getManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager +} + // ensureMCPInitialized loads MCP servers/tools once so both Run() and direct // agent mode share the same initialization path. func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 5cdac186c..2f8808dac 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -2269,6 +2269,75 @@ func TestProcessMessage_CommandOutcomes(t *testing.T) { } } +func TestProcessMessage_MCPCommandsHandledWithoutLLMCall(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + deferred := true + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + Dimensions: []string{"chat"}, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{Enabled: true}, + Discovery: config.ToolDiscoveryConfig{Enabled: true}, + Servers: map[string]config.MCPServerConfig{ + "github": { + Enabled: true, + Deferred: &deferred, + }, + }, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + baseContext := bus.InboundContext{ + Channel: "whatsapp", + ChatID: "chat1", + ChatType: "direct", + SenderID: "user1", + } + + listResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/list mcp", + }) + if !strings.Contains(listResp, "- `github`") || !strings.Contains(listResp, "Deferred: yes") { + t.Fatalf("unexpected /list mcp reply: %q", listResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /list mcp, calls=%d", provider.calls) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Context: baseContext, + Content: "/show mcp github", + }) + if showResp != "MCP server 'github' is configured but not connected" { + t.Fatalf("unexpected /show mcp reply: %q", showResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /show mcp, calls=%d", provider.calls) + } +} + func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index 79e63d9b7..b42328c96 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -36,10 +36,10 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) { t.Fatalf("/help handler error: %v", err) } // Now uses auto-generated EffectiveUsage which includes agents - if !strings.Contains(reply, "/show [model|channel|agents]") { + if !strings.Contains(reply, "/show [model|channel|agents|mcp ]") { t.Fatalf("/help reply missing /show usage, got %q", reply) } - if !strings.Contains(reply, "/list [models|channels|agents|skills]") { + if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") { t.Fatalf("/help reply missing /list usage, got %q", reply) } if !strings.Contains(reply, "/use ") { @@ -174,6 +174,90 @@ func TestBuiltinListSkills_UsesRuntimeSkillNames(t *testing.T) { } } +func TestBuiltinListMCP_UsesRuntimeServerStatus(t *testing.T) { + rt := &Runtime{ + ListMCPServers: func(context.Context) []MCPServerInfo { + return []MCPServerInfo{ + {Name: "filesystem", Enabled: true, Deferred: true, Connected: false}, + {Name: "github", Enabled: true, Deferred: false, Connected: true, ToolCount: 3}, + } + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/list mcp", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/list mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "- `filesystem`\n Enabled: yes\n Deferred: yes\n Connected: no\n Active tools: unavailable") { + t.Fatalf("/list mcp reply=%q, want formatted filesystem block", reply) + } + if !strings.Contains(reply, "- `github`\n Enabled: yes\n Deferred: no\n Connected: yes\n Active tools: 3") { + t.Fatalf("/list mcp reply=%q, want formatted github block", reply) + } +} + +func TestBuiltinShowMCP_UsesRuntimeToolNames(t *testing.T) { + rt := &Runtime{ + ListMCPTools: func(_ context.Context, serverName string) ([]MCPToolInfo, error) { + if serverName != "github" { + t.Fatalf("serverName=%q, want github", serverName) + } + return []MCPToolInfo{ + { + Name: "create_issue", + Description: "Create a GitHub issue", + Parameters: []MCPToolParameterInfo{ + {Name: "body", Type: "string", Description: "Issue body"}, + {Name: "title", Type: "string", Description: "Issue title", Required: true}, + }, + }, + { + Name: "list_prs", + Description: "List open pull requests", + }, + }, nil + }, + } + defs := BuiltinDefinitions() + ex := NewExecutor(NewRegistry(defs), rt) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/show mcp github", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("/show mcp: outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if !strings.Contains(reply, "Active MCP tools for `github`:\n- `create_issue`") { + t.Fatalf("/show mcp reply=%q, want tool header", reply) + } + if !strings.Contains(reply, "Description: Create a GitHub issue") { + t.Fatalf("/show mcp reply=%q, want description", reply) + } + if !strings.Contains(reply, " - `title` (string, required): Issue title") { + t.Fatalf("/show mcp reply=%q, want required parameter", reply) + } + if !strings.Contains(reply, " - `body` (string): Issue body") { + t.Fatalf("/show mcp reply=%q, want optional parameter", reply) + } + if !strings.Contains(reply, "- `list_prs`\n Description: List open pull requests\n Parameters: none") { + t.Fatalf("/show mcp reply=%q, want empty parameter block", reply) + } +} + func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) { defs := BuiltinDefinitions() ex := NewExecutor(NewRegistry(defs), nil) diff --git a/pkg/commands/cmd_list.go b/pkg/commands/cmd_list.go index 7186a6c25..c0021e55c 100644 --- a/pkg/commands/cmd_list.go +++ b/pkg/commands/cmd_list.go @@ -64,6 +64,11 @@ func listCommand() Definition { )) }, }, + { + Name: "mcp", + Description: "Configured MCP servers", + Handler: listMCPServersHandler(), + }, }, } } diff --git a/pkg/commands/cmd_show.go b/pkg/commands/cmd_show.go index c655e6880..cda7aaea7 100644 --- a/pkg/commands/cmd_show.go +++ b/pkg/commands/cmd_show.go @@ -33,6 +33,12 @@ func showCommand() Definition { Description: "Registered agents", Handler: agentsHandler(), }, + { + Name: "mcp", + Description: "Active tools for an MCP server", + ArgsUsage: "", + Handler: showMCPToolsHandler(), + }, }, } } diff --git a/pkg/commands/handler_mcp.go b/pkg/commands/handler_mcp.go new file mode 100644 index 000000000..c3dcc1147 --- /dev/null +++ b/pkg/commands/handler_mcp.go @@ -0,0 +1,106 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func listMCPServersHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPServers == nil { + return req.Reply(unavailableMsg) + } + + servers := rt.ListMCPServers(ctx) + if len(servers) == 0 { + return req.Reply("No MCP servers configured") + } + + header := "Configured MCP Servers:" + if rt.Config != nil && !rt.Config.Tools.IsToolEnabled("mcp") { + header = "Configured MCP Servers (integration disabled):" + } + + lines := make([]string, 0, len(servers)*5+1) + lines = append(lines, header) + for idx, server := range servers { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", server.Name)) + lines = append(lines, fmt.Sprintf(" Enabled: %s", yesNo(server.Enabled))) + lines = append(lines, fmt.Sprintf(" Deferred: %s", yesNo(server.Deferred))) + lines = append(lines, fmt.Sprintf(" Connected: %s", yesNo(server.Connected))) + if server.Connected { + lines = append(lines, fmt.Sprintf(" Active tools: %d", server.ToolCount)) + continue + } + lines = append(lines, " Active tools: unavailable") + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func showMCPToolsHandler() Handler { + return func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListMCPTools == nil { + return req.Reply(unavailableMsg) + } + + serverName := nthToken(req.Text, 2) + if serverName == "" { + return req.Reply("Usage: /show mcp ") + } + + tools, err := rt.ListMCPTools(ctx, serverName) + if err != nil { + return req.Reply(err.Error()) + } + if len(tools) == 0 { + return req.Reply(fmt.Sprintf("MCP server '%s' has no active tools", serverName)) + } + + lines := make([]string, 0, len(tools)*6+1) + lines = append(lines, fmt.Sprintf("Active MCP tools for `%s`:", serverName)) + for idx, tool := range tools { + if idx > 0 { + lines = append(lines, "") + } + lines = append(lines, fmt.Sprintf("- `%s`", tool.Name)) + lines = append(lines, fmt.Sprintf(" Description: %s", tool.Description)) + if len(tool.Parameters) == 0 { + lines = append(lines, " Parameters: none") + continue + } + + lines = append(lines, " Parameters:") + for _, param := range tool.Parameters { + line := fmt.Sprintf(" - `%s`", param.Name) + if param.Type != "" { + line += fmt.Sprintf(" (%s", param.Type) + if param.Required { + line += ", required" + } + line += ")" + } else if param.Required { + line += " (required)" + } + if param.Description != "" { + line += ": " + param.Description + } + lines = append(lines, line) + } + } + + return req.Reply(strings.Join(lines, "\n")) + } +} + +func yesNo(v bool) string { + if v { + return "yes" + } + return "no" +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 68c286dde..c17b7cf1c 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -6,6 +6,27 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +type MCPServerInfo struct { + Name string + Enabled bool + Deferred bool + Connected bool + ToolCount int +} + +type MCPToolParameterInfo struct { + Name string + Type string + Description string + Required bool +} + +type MCPToolInfo struct { + Name string + Description string + Parameters []MCPToolParameterInfo +} + // ContextStats describes current session context window usage. type ContextStats struct { UsedTokens int @@ -25,6 +46,8 @@ type Runtime struct { ListAgentIDs func() []string ListDefinitions func() []Definition ListSkillNames func() []string + ListMCPServers func(ctx context.Context) []MCPServerInfo + ListMCPTools func(ctx context.Context, serverName string) ([]MCPToolInfo, error) GetEnabledChannels func() []string GetActiveTurn func() any // Returning any to avoid circular dependency with agent package GetContextStats func() *ContextStats