feat(commands): add MCP slash commands and tool details

This commit is contained in:
afjcjsbx
2026-04-15 19:38:30 +02:00
parent 6ca7311273
commit 276f5425f0
11 changed files with 521 additions and 4 deletions
+208
View File
@@ -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 {
+6
View File
@@ -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 {
+69
View File
@@ -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 {
+86 -2
View File
@@ -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 <server>]") {
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 <skill> <message>") {
@@ -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)
+5
View File
@@ -64,6 +64,11 @@ func listCommand() Definition {
))
},
},
{
Name: "mcp",
Description: "Configured MCP servers",
Handler: listMCPServersHandler(),
},
},
}
}
+6
View File
@@ -33,6 +33,12 @@ func showCommand() Definition {
Description: "Registered agents",
Handler: agentsHandler(),
},
{
Name: "mcp",
Description: "Active tools for an MCP server",
ArgsUsage: "<server>",
Handler: showMCPToolsHandler(),
},
},
}
}
+106
View File
@@ -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 <server>")
}
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"
}
+23
View File
@@ -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