Merge pull request #2535 from afjcjsbx/feat/mcp-channel-commands
feat(commands): add MCP slash commands and tool details
This commit is contained in:
commit
68ceb54b36
12 changed files with 570 additions and 4 deletions
|
|
@ -44,6 +44,8 @@ Telegram auto-registers PicoClaw's top-level bot commands at startup, including
|
||||||
Skill-related commands:
|
Skill-related commands:
|
||||||
|
|
||||||
- `/list skills` lists the installed skills visible to the current agent.
|
- `/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 <server>` lists the active tools for a connected MCP server.
|
||||||
- `/use <skill> <message>` forces a skill for a single request.
|
- `/use <skill> <message>` forces a skill for a single request.
|
||||||
- `/use <skill>` arms the skill for your next message in the same chat.
|
- `/use <skill>` arms the skill for your next message in the same chat.
|
||||||
- `/use clear` clears a pending skill override.
|
- `/use clear` clears a pending skill override.
|
||||||
|
|
@ -52,6 +54,8 @@ Examples:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/list skills
|
/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
|
||||||
/use git
|
/use git
|
||||||
explain how to squash the last 3 commits
|
explain how to squash the last 3 commits
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 skills`
|
||||||
|
- `/list mcp`
|
||||||
|
- `/show mcp <server>`
|
||||||
- `/use <skill> <message>`
|
- `/use <skill> <message>`
|
||||||
- `/use <skill>` and then send the actual request in the next message
|
- `/use <skill>` and then send the actual request in the next message
|
||||||
- `/use clear`
|
- `/use clear`
|
||||||
|
|
|
||||||
|
|
@ -98,9 +98,11 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||||
|
|
||||||
### Using Skills From Chat Channels
|
### 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 skills` shows the installed skill names available to the current agent.
|
||||||
|
- `/list mcp` shows configured MCP servers with enabled/deferred/connected status.
|
||||||
|
- `/show mcp <server>` shows the active tools exposed by a connected MCP server.
|
||||||
- `/use <skill> <message>` forces a specific skill for a single request.
|
- `/use <skill> <message>` forces a specific skill for a single request.
|
||||||
- `/use <skill>` arms that skill for your next message in the same chat session.
|
- `/use <skill>` arms that skill for your next message in the same chat session.
|
||||||
- `/use clear` cancels a pending skill override created by `/use <skill>`.
|
- `/use clear` cancels a pending skill override created by `/use <skill>`.
|
||||||
|
|
@ -110,6 +112,8 @@ Examples:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
/list skills
|
/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
|
||||||
/btw remind me what we already decided about the deploy plan
|
/btw remind me what we already decided about the deploy plan
|
||||||
/use italiapersonalfinance
|
/use italiapersonalfinance
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,15 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/commands"
|
"github.com/sipeed/picoclaw/pkg/commands"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -133,6 +137,120 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
ListAgentIDs: registry.ListAgentIDs,
|
ListAgentIDs: registry.ListAgentIDs,
|
||||||
ListDefinitions: al.cmdRegistry.Definitions,
|
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 {
|
GetEnabledChannels: func() []string {
|
||||||
if al.channelManager == nil {
|
if al.channelManager == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -236,6 +354,96 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||||
return rt
|
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) {
|
func (al *AgentLoop) setPendingSkills(sessionKey string, skillNames []string) {
|
||||||
sessionKey = strings.TrimSpace(sessionKey)
|
sessionKey = strings.TrimSpace(sessionKey)
|
||||||
if sessionKey == "" || len(skillNames) == 0 {
|
if sessionKey == "" || len(skillNames) == 0 {
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,12 @@ func (r *mcpRuntime) hasManager() bool {
|
||||||
return r.manager != nil
|
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
|
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
|
||||||
// agent mode share the same initialization path.
|
// agent mode share the same initialization path.
|
||||||
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
|
|
@ -100,6 +106,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
|
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
|
||||||
|
al.mcp.setInitErr(fmt.Errorf("failed to load MCP servers: %w", err))
|
||||||
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
|
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package agent
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -133,3 +134,48 @@ func TestServerIsDeferred(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) {
|
||||||
|
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||||
|
defer cleanup()
|
||||||
|
defer al.Close()
|
||||||
|
|
||||||
|
cfg.Tools = config.ToolsConfig{
|
||||||
|
MCP: config.MCPConfig{
|
||||||
|
ToolConfig: config.ToolConfig{Enabled: true},
|
||||||
|
Servers: map[string]config.MCPServerConfig{
|
||||||
|
"broken": {
|
||||||
|
Enabled: true,
|
||||||
|
Command: "picoclaw-command-that-does-not-exist-for-mcp-tests",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := al.ensureMCPInitialized(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ensureMCPInitialized() error = nil, want load failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to load MCP servers") {
|
||||||
|
t.Fatalf("ensureMCPInitialized() error = %q, want wrapped load failure", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
initErr := al.mcp.getInitErr()
|
||||||
|
if initErr == nil {
|
||||||
|
t.Fatal("getInitErr() = nil, want cached load failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(initErr.Error(), "failed to load MCP servers") {
|
||||||
|
t.Fatalf("getInitErr() = %q, want wrapped load failure", initErr.Error())
|
||||||
|
}
|
||||||
|
if al.mcp.getManager() != nil {
|
||||||
|
t.Fatal("expected MCP manager to remain nil after load failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = al.ensureMCPInitialized(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("second ensureMCPInitialized() error = nil, want cached load failure")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "failed to load MCP servers") {
|
||||||
|
t.Fatalf("second ensureMCPInitialized() error = %q, want wrapped load failure", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2272,6 +2272,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) {
|
func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,10 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||||
t.Fatalf("/help handler error: %v", err)
|
t.Fatalf("/help handler error: %v", err)
|
||||||
}
|
}
|
||||||
// Now uses auto-generated EffectiveUsage which includes agents
|
// 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)
|
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)
|
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
||||||
}
|
}
|
||||||
if !strings.Contains(reply, "/use <skill> <message>") {
|
if !strings.Contains(reply, "/use <skill> <message>") {
|
||||||
|
|
@ -174,6 +174,92 @@ 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) {
|
func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) {
|
||||||
defs := BuiltinDefinitions()
|
defs := BuiltinDefinitions()
|
||||||
ex := NewExecutor(NewRegistry(defs), nil)
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,11 @@ func listCommand() Definition {
|
||||||
))
|
))
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "mcp",
|
||||||
|
Description: "Configured MCP servers",
|
||||||
|
Handler: listMCPServersHandler(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ func showCommand() Definition {
|
||||||
Description: "Registered agents",
|
Description: "Registered agents",
|
||||||
Handler: agentsHandler(),
|
Handler: agentsHandler(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "mcp",
|
||||||
|
Description: "Active tools for an MCP server",
|
||||||
|
ArgsUsage: "<server>",
|
||||||
|
Handler: showMCPToolsHandler(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
106
pkg/commands/handler_mcp.go
Normal file
106
pkg/commands/handler_mcp.go
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,27 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"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.
|
// ContextStats describes current session context window usage.
|
||||||
type ContextStats struct {
|
type ContextStats struct {
|
||||||
UsedTokens int
|
UsedTokens int
|
||||||
|
|
@ -25,6 +46,8 @@ type Runtime struct {
|
||||||
ListAgentIDs func() []string
|
ListAgentIDs func() []string
|
||||||
ListDefinitions func() []Definition
|
ListDefinitions func() []Definition
|
||||||
ListSkillNames func() []string
|
ListSkillNames func() []string
|
||||||
|
ListMCPServers func(ctx context.Context) []MCPServerInfo
|
||||||
|
ListMCPTools func(ctx context.Context, serverName string) ([]MCPToolInfo, error)
|
||||||
GetEnabledChannels func() []string
|
GetEnabledChannels func() []string
|
||||||
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
GetActiveTurn func() any // Returning any to avoid circular dependency with agent package
|
||||||
GetContextStats func() *ContextStats
|
GetContextStats func() *ContextStats
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue