From f043de5384a9b43be62daab850808cd8b1af0352 Mon Sep 17 00:00:00 2001 From: lpf Date: Sun, 8 Mar 2026 11:08:41 +0800 Subject: [PATCH] feat: expand mcp transports and skill execution --- README.md | 4 +- README_EN.md | 4 +- config.example.json | 3 +- pkg/agent/loop.go | 82 +++- pkg/agent/loop_allowlist_test.go | 57 +++ pkg/agent/loop_skill_exec_test.go | 37 ++ pkg/agent/runtime_admin.go | 53 +++ pkg/agent/runtime_admin_test.go | 21 + pkg/api/server.go | 210 +++++++++- pkg/config/config.go | 2 + pkg/config/validate.go | 34 +- pkg/tools/mcp.go | 531 +++++++++++++++++++++++- pkg/tools/mcp_test.go | 246 ++++++++++- pkg/tools/skill_exec.go | 34 +- pkg/tools/skill_exec_test.go | 27 ++ pkg/tools/tool_allowlist_groups.go | 6 + pkg/tools/tool_allowlist_groups_test.go | 5 +- webui/src/i18n/index.ts | 8 + webui/src/pages/MCP.tsx | 147 ++++++- webui/src/pages/SubagentProfiles.tsx | 3 + webui/src/pages/Subagents.tsx | 17 +- 21 files changed, 1447 insertions(+), 84 deletions(-) create mode 100644 pkg/agent/loop_skill_exec_test.go create mode 100644 pkg/tools/skill_exec_test.go diff --git a/README.md b/README.md index 0fcd349..f24b32c 100644 --- a/README.md +++ b/README.md @@ -202,11 +202,13 @@ user -> main -> worker -> main -> user ## MCP 服务支持 -ClawGo 现在支持通过 `tools.mcp` 接入 `stdio` 型 MCP server。 +ClawGo 现在支持通过 `tools.mcp` 接入 `stdio`、`http`、`streamable_http`、`sse` 型 MCP server。 - 先在 `config.json -> tools.mcp.servers` 里声明 server - 当前支持 `list_servers`、`list_tools`、`call_tool`、`list_resources`、`read_resource`、`list_prompts`、`get_prompt` - 启动时会自动发现远端 MCP tools,并注册为本地工具,命名格式为 `mcp____` +- `permission=workspace`(默认)时,`working_dir` 会按 workspace 解析,并且必须留在 workspace 内 +- `permission=full` 时,`working_dir` 可指向 `/` 下任意绝对路径,但实际访问权限仍然继承运行 `clawgo` 的 Linux 用户权限 示例配置可直接参考 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) 中的 `tools.mcp` 段落。 diff --git a/README_EN.md b/README_EN.md index d084f65..931c8b0 100644 --- a/README_EN.md +++ b/README_EN.md @@ -202,11 +202,13 @@ See [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) ## MCP Server Support -ClawGo now supports `stdio` MCP servers through `tools.mcp`. +ClawGo now supports `stdio`, `http`, `streamable_http`, and `sse` MCP servers through `tools.mcp`. - declare each server under `config.json -> tools.mcp.servers` - the bridge supports `list_servers`, `list_tools`, `call_tool`, `list_resources`, `read_resource`, `list_prompts`, and `get_prompt` - on startup, ClawGo discovers remote MCP tools and registers them as local tools using the `mcp____` naming pattern +- with `permission=workspace` (default), `working_dir` is resolved inside the workspace and must remain within it +- with `permission=full`, `working_dir` may point to any absolute path including `/`, but access still inherits the permissions of the Linux user running `clawgo` See the `tools.mcp` section in [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json). diff --git a/config.example.json b/config.example.json index b76f61c..8f57eeb 100644 --- a/config.example.json +++ b/config.example.json @@ -264,7 +264,8 @@ "command": "npx", "args": ["-y", "@upstash/context7-mcp"], "env": {}, - "working_dir": "/absolute/path/to/project", + "permission": "workspace", + "working_dir": "tools/context7", "description": "Example MCP server", "package": "@upstash/context7-mcp" } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 29a2cdb..f5d1278 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -105,6 +105,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry.Register(tools.NewAliasTool("edit", "Edit file content (OpenClaw-compatible alias of edit_file)", tools.NewEditFileTool(workspace), map[string]string{"file_path": "path", "old_string": "oldText", "new_string": "newText"})) toolsRegistry.Register(tools.NewExecTool(cfg.Tools.Shell, workspace, processManager)) toolsRegistry.Register(tools.NewProcessTool(processManager)) + toolsRegistry.Register(tools.NewSkillExecTool(workspace)) nodesManager := nodes.DefaultManager() nodesManager.SetAuditPath(filepath.Join(workspace, "memory", "nodes-audit.jsonl")) nodesManager.SetStatePath(filepath.Join(workspace, "memory", "nodes-state.json")) @@ -935,7 +936,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "max": al.maxIterations, }) - toolDefs := al.tools.GetDefinitions() + toolDefs := al.filteredToolDefinitionsForContext(ctx) providerToolDefs := al.buildProviderToolDefs(toolDefs) // Log LLM request details @@ -1301,7 +1302,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe for iteration < al.maxIterations { iteration++ - toolDefs := al.tools.GetDefinitions() + toolDefs := al.filteredToolDefinitionsForContext(ctx) providerToolDefs := al.buildProviderToolDefs(toolDefs) // Log LLM request details @@ -1459,6 +1460,37 @@ func (al *AgentLoop) buildProviderToolDefs(toolDefs []map[string]interface{}) [] return providerToolDefs } +func (al *AgentLoop) filteredToolDefinitionsForContext(ctx context.Context) []map[string]interface{} { + if al == nil || al.tools == nil { + return nil + } + return filterToolDefinitionsByContext(ctx, al.tools.GetDefinitions()) +} + +func filterToolDefinitionsByContext(ctx context.Context, toolDefs []map[string]interface{}) []map[string]interface{} { + allow := toolAllowlistFromContext(ctx) + if len(allow) == 0 { + return toolDefs + } + + filtered := make([]map[string]interface{}, 0, len(toolDefs)) + for _, td := range toolDefs { + fnRaw, ok := td["function"].(map[string]interface{}) + if !ok { + continue + } + name, _ := fnRaw["name"].(string) + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" { + continue + } + if isToolNameAllowed(allow, name) || isImplicitlyAllowedSubagentTool(name) { + filtered = append(filtered, td) + } + } + return filtered +} + func (al *AgentLoop) buildResponsesOptions(sessionKey string, maxTokens int64, temperature float64) map[string]interface{} { options := map[string]interface{}{ "max_tokens": maxTokens, @@ -1702,10 +1734,22 @@ func (al *AgentLoop) GetToolCatalog() []map[string]interface{} { if fmt.Sprint(item["source"]) != "mcp" { item["source"] = "local" } + name := strings.TrimSpace(fmt.Sprint(item["name"])) + item["visibility"] = map[string]interface{}{ + "main_agent": true, + "subagent": subagentToolVisibilityMode(name), + } } return items } +func subagentToolVisibilityMode(name string) string { + if isImplicitlyAllowedSubagentTool(name) { + return "inherited" + } + return "allowlist" +} + // formatMessagesForLog formats messages for logging func formatMessagesForLog(messages []providers.Message) string { if len(messages) == 0 { @@ -1804,11 +1848,32 @@ func withToolContextArgs(toolName string, args map[string]interface{}, channel, return next } +func withToolRuntimeArgs(ctx context.Context, toolName string, args map[string]interface{}) map[string]interface{} { + if strings.TrimSpace(toolName) != "skill_exec" { + return args + } + ns := normalizeMemoryNamespace(memoryNamespaceFromContext(ctx)) + callerAgent := ns + callerScope := "subagent" + if callerAgent == "" || callerAgent == "main" { + callerAgent = "main" + callerScope = "main_agent" + } + next := make(map[string]interface{}, len(args)+2) + for k, v := range args { + next[k] = v + } + next["caller_agent"] = callerAgent + next["caller_scope"] = callerScope + return next +} + func (al *AgentLoop) executeToolCall(ctx context.Context, toolName string, args map[string]interface{}, currentChannel, currentChatID string) (string, error) { if err := ensureToolAllowedByContext(ctx, toolName, args); err != nil { return "", err } args = withToolMemoryNamespaceArgs(toolName, args, memoryNamespaceFromContext(ctx)) + args = withToolRuntimeArgs(ctx, toolName, args) if shouldSuppressSelfMessageSend(toolName, args, currentChannel, currentChatID) { return "Suppressed message tool self-send in current chat; assistant will reply via normal outbound.", nil } @@ -1887,7 +1952,7 @@ func ensureToolAllowedByContext(ctx context.Context, toolName string, args map[s if name == "" { return fmt.Errorf("tool name is empty") } - if !isToolNameAllowed(allow, name) { + if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedSubagentTool(name) { return fmt.Errorf("tool '%s' is not allowed by subagent profile", toolName) } @@ -1914,13 +1979,22 @@ func validateParallelAllowlistArgs(allow map[string]struct{}, args map[string]in if name == "" { continue } - if !isToolNameAllowed(allow, name) { + if !isToolNameAllowed(allow, name) && !isImplicitlyAllowedSubagentTool(name) { return fmt.Errorf("tool 'parallel' contains disallowed call[%d]: %s", i, tool) } } return nil } +func isImplicitlyAllowedSubagentTool(name string) bool { + switch strings.ToLower(strings.TrimSpace(name)) { + case "skill_exec": + return true + default: + return false + } +} + func normalizeToolAllowlist(in []string) map[string]struct{} { expanded := tools.ExpandToolAllowlistEntries(in) if len(expanded) == 0 { diff --git a/pkg/agent/loop_allowlist_test.go b/pkg/agent/loop_allowlist_test.go index 318f59f..550784f 100644 --- a/pkg/agent/loop_allowlist_test.go +++ b/pkg/agent/loop_allowlist_test.go @@ -19,6 +19,9 @@ func TestEnsureToolAllowedByContext(t *testing.T) { if err := ensureToolAllowedByContext(restricted, "write_file", map[string]interface{}{}); err == nil { t.Fatalf("expected disallowed tool to fail") } + if err := ensureToolAllowedByContext(restricted, "skill_exec", map[string]interface{}{}); err != nil { + t.Fatalf("expected skill_exec to bypass subagent allowlist, got: %v", err) + } } func TestEnsureToolAllowedByContextParallelNested(t *testing.T) { @@ -41,6 +44,15 @@ func TestEnsureToolAllowedByContextParallelNested(t *testing.T) { if err := ensureToolAllowedByContext(restricted, "parallel", badArgs); err == nil { t.Fatalf("expected parallel with disallowed nested tool to fail") } + + skillArgs := map[string]interface{}{ + "calls": []interface{}{ + map[string]interface{}{"tool": "skill_exec", "arguments": map[string]interface{}{"skill": "demo", "script": "scripts/run.sh"}}, + }, + } + if err := ensureToolAllowedByContext(restricted, "parallel", skillArgs); err != nil { + t.Fatalf("expected parallel with nested skill_exec to pass, got: %v", err) + } } func TestEnsureToolAllowedByContext_GroupAllowlist(t *testing.T) { @@ -52,3 +64,48 @@ func TestEnsureToolAllowedByContext_GroupAllowlist(t *testing.T) { t.Fatalf("expected files_read group to block write_file") } } + +func TestFilteredToolDefinitionsForContext(t *testing.T) { + ctx := withToolAllowlistContext(context.Background(), []string{"read_file", "parallel"}) + toolDefs := []map[string]interface{}{ + {"function": map[string]interface{}{"name": "read_file", "description": "read", "parameters": map[string]interface{}{}}}, + {"function": map[string]interface{}{"name": "write_file", "description": "write", "parameters": map[string]interface{}{}}}, + {"function": map[string]interface{}{"name": "parallel", "description": "parallel", "parameters": map[string]interface{}{}}}, + {"function": map[string]interface{}{"name": "skill_exec", "description": "skill", "parameters": map[string]interface{}{}}}, + } + filtered := filterToolDefinitionsByContext(ctx, toolDefs) + got := map[string]bool{} + for _, td := range filtered { + fnRaw, _ := td["function"].(map[string]interface{}) + name, _ := fnRaw["name"].(string) + got[name] = true + } + if !got["read_file"] || !got["parallel"] || !got["skill_exec"] { + t.Fatalf("expected filtered tools to include read_file, parallel, and inherited skill_exec, got: %v", got) + } + if got["write_file"] { + t.Fatalf("expected filtered tools to exclude write_file, got: %v", got) + } +} + +func TestWithToolRuntimeArgsForSkillExec(t *testing.T) { + mainArgs := withToolRuntimeArgs(context.Background(), "skill_exec", map[string]interface{}{"skill": "demo"}) + if mainArgs["caller_agent"] != "main" || mainArgs["caller_scope"] != "main_agent" { + t.Fatalf("expected main agent runtime args, got: %#v", mainArgs) + } + + subagentCtx := withMemoryNamespaceContext(context.Background(), "coder") + subArgs := withToolRuntimeArgs(subagentCtx, "skill_exec", map[string]interface{}{"skill": "demo"}) + if subArgs["caller_agent"] != "coder" || subArgs["caller_scope"] != "subagent" { + t.Fatalf("expected subagent runtime args, got: %#v", subArgs) + } +} + +func TestSubagentToolVisibilityMode(t *testing.T) { + if got := subagentToolVisibilityMode("skill_exec"); got != "inherited" { + t.Fatalf("expected skill_exec inherited, got %q", got) + } + if got := subagentToolVisibilityMode("write_file"); got != "allowlist" { + t.Fatalf("expected write_file allowlist, got %q", got) + } +} diff --git a/pkg/agent/loop_skill_exec_test.go b/pkg/agent/loop_skill_exec_test.go new file mode 100644 index 0000000..00adddc --- /dev/null +++ b/pkg/agent/loop_skill_exec_test.go @@ -0,0 +1,37 @@ +package agent + +import ( + "context" + "testing" + + "clawgo/pkg/bus" + "clawgo/pkg/config" + "clawgo/pkg/providers" +) + +type stubLLMProvider struct{} + +func (stubLLMProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok", FinishReason: "stop"}, nil +} + +func (stubLLMProvider) GetDefaultModel() string { return "stub-model" } + +func TestNewAgentLoopRegistersSkillExec(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = t.TempDir() + + loop := NewAgentLoop(cfg, bus.NewMessageBus(), stubLLMProvider{}, nil) + catalog := loop.GetToolCatalog() + + found := false + for _, item := range catalog { + if item["name"] == "skill_exec" { + found = true + break + } + } + if !found { + t.Fatalf("expected skill_exec in tool catalog, got %v", catalog) + } +} diff --git a/pkg/agent/runtime_admin.go b/pkg/agent/runtime_admin.go index d448302..0dff513 100644 --- a/pkg/agent/runtime_admin.go +++ b/pkg/agent/runtime_admin.go @@ -124,6 +124,7 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a } } } + toolInfo := al.describeSubagentTools(subcfg.Tools.Allowlist) items = append(items, map[string]interface{}{ "agent_id": agentID, "enabled": subcfg.Enabled, @@ -140,6 +141,9 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a "prompt_file_found": promptFileFound, "memory_namespace": subcfg.MemoryNamespace, "tool_allowlist": append([]string(nil), subcfg.Tools.Allowlist...), + "tool_visibility": toolInfo, + "effective_tools": toolInfo["effective_tools"], + "inherited_tools": toolInfo["inherited_tools"], "routing_keywords": routeKeywordsForRegistry(cfg.Agents.Router.Rules, agentID), "managed_by": "config.json", }) @@ -151,6 +155,7 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a if strings.TrimSpace(profile.ManagedBy) != "node_registry" { continue } + toolInfo := al.describeSubagentTools(profile.ToolAllowlist) items = append(items, map[string]interface{}{ "agent_id": profile.AgentID, "enabled": strings.EqualFold(strings.TrimSpace(profile.Status), "active"), @@ -167,6 +172,9 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a "prompt_file_found": false, "memory_namespace": profile.MemoryNamespace, "tool_allowlist": append([]string(nil), profile.ToolAllowlist...), + "tool_visibility": toolInfo, + "effective_tools": toolInfo["effective_tools"], + "inherited_tools": toolInfo["inherited_tools"], "routing_keywords": []string{}, "managed_by": profile.ManagedBy, }) @@ -435,6 +443,51 @@ func (al *AgentLoop) HandleSubagentRuntime(ctx context.Context, action string, a } } +func (al *AgentLoop) describeSubagentTools(allowlist []string) map[string]interface{} { + inherited := implicitSubagentTools() + allTools := make([]string, 0) + if al != nil && al.tools != nil { + allTools = al.tools.List() + sort.Strings(allTools) + } + + normalizedAllow := normalizeToolAllowlist(allowlist) + mode := "allowlist" + effective := make([]string, 0) + if len(normalizedAllow) == 0 { + mode = "unrestricted" + effective = append(effective, allTools...) + } else if _, ok := normalizedAllow["*"]; ok { + mode = "all" + effective = append(effective, allTools...) + } else if _, ok := normalizedAllow["all"]; ok { + mode = "all" + effective = append(effective, allTools...) + } else { + for _, name := range allTools { + if isToolNameAllowed(normalizedAllow, name) || isImplicitlyAllowedSubagentTool(name) { + effective = append(effective, name) + } + } + } + return map[string]interface{}{ + "mode": mode, + "raw_allowlist": append([]string(nil), allowlist...), + "inherited_tools": inherited, + "inherited_tool_count": len(inherited), + "effective_tools": effective, + "effective_tool_count": len(effective), + } +} + +func implicitSubagentTools() []string { + out := make([]string, 0, 1) + if isImplicitlyAllowedSubagentTool("skill_exec") { + out = append(out, "skill_exec") + } + return out +} + func mergeSubagentStream(events []tools.SubagentRunEvent, messages []tools.AgentMessage) []map[string]interface{} { items := make([]map[string]interface{}, 0, len(events)+len(messages)) for _, evt := range events { diff --git a/pkg/agent/runtime_admin_test.go b/pkg/agent/runtime_admin_test.go index 3cbc019..f6edbe5 100644 --- a/pkg/agent/runtime_admin_test.go +++ b/pkg/agent/runtime_admin_test.go @@ -161,6 +161,27 @@ func TestHandleSubagentRuntimeRegistryAndToggleEnabled(t *testing.T) { if !ok || len(items) < 2 { t.Fatalf("expected registry items, got %#v", payload["items"]) } + var tester map[string]interface{} + for _, item := range items { + if item["agent_id"] == "tester" { + tester = item + break + } + } + if tester == nil { + t.Fatalf("expected tester registry item, got %#v", items) + } + toolVisibility, _ := tester["tool_visibility"].(map[string]interface{}) + if toolVisibility == nil { + t.Fatalf("expected tool_visibility in tester registry item, got %#v", tester) + } + if toolVisibility["mode"] != "allowlist" { + t.Fatalf("expected tester tool mode allowlist, got %#v", toolVisibility) + } + inherited, _ := tester["inherited_tools"].([]string) + if len(inherited) != 1 || inherited[0] != "skill_exec" { + t.Fatalf("expected inherited skill_exec, got %#v", tester["inherited_tools"]) + } _, err = loop.HandleSubagentRuntime(context.Background(), "set_config_subagent_enabled", map[string]interface{}{ "agent_id": "tester", diff --git a/pkg/api/server.go b/pkg/api/server.go index 5790051..4e2df2a 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -617,13 +617,132 @@ func (s *Server) handleWebUITools(w http.ResponseWriter, r *http.Request) { mcpItems = append(mcpItems, item) } } + serverChecks := []map[string]interface{}{} + if strings.TrimSpace(s.configPath) != "" { + if cfg, err := cfgpkg.LoadConfig(s.configPath); err == nil { + serverChecks = buildMCPServerChecks(cfg) + } + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "tools": toolsList, - "mcp_tools": mcpItems, + "tools": toolsList, + "mcp_tools": mcpItems, + "mcp_server_checks": serverChecks, }) } +func buildMCPServerChecks(cfg *cfgpkg.Config) []map[string]interface{} { + if cfg == nil { + return nil + } + names := make([]string, 0, len(cfg.Tools.MCP.Servers)) + for name := range cfg.Tools.MCP.Servers { + names = append(names, name) + } + sort.Strings(names) + items := make([]map[string]interface{}, 0, len(names)) + for _, name := range names { + server := cfg.Tools.MCP.Servers[name] + transport := strings.ToLower(strings.TrimSpace(server.Transport)) + if transport == "" { + transport = "stdio" + } + command := strings.TrimSpace(server.Command) + status := "missing_command" + message := "command is empty" + resolved := "" + missingCommand := false + if !server.Enabled { + status = "disabled" + message = "server is disabled" + } else if transport != "stdio" { + status = "not_applicable" + message = "command check not required for non-stdio transport" + } else if command != "" { + if filepath.IsAbs(command) { + if info, err := os.Stat(command); err == nil && !info.IsDir() { + status = "ok" + message = "command found" + resolved = command + } else { + status = "missing_command" + message = fmt.Sprintf("command not found: %s", command) + missingCommand = true + } + } else if path, err := exec.LookPath(command); err == nil { + status = "ok" + message = "command found" + resolved = path + } else { + status = "missing_command" + message = fmt.Sprintf("command not found in PATH: %s", command) + missingCommand = true + } + } + installSpec := inferMCPInstallSpec(server) + items = append(items, map[string]interface{}{ + "name": name, + "enabled": server.Enabled, + "transport": transport, + "status": status, + "message": message, + "command": command, + "resolved": resolved, + "package": installSpec.Package, + "installer": installSpec.Installer, + "installable": missingCommand && installSpec.AutoInstallSupported, + "missing_command": missingCommand, + }) + } + return items +} + +type mcpInstallSpec struct { + Installer string + Package string + AutoInstallSupported bool +} + +func inferMCPInstallSpec(server cfgpkg.MCPServerConfig) mcpInstallSpec { + if pkgName := strings.TrimSpace(server.Package); pkgName != "" { + return mcpInstallSpec{Installer: "npm", Package: pkgName, AutoInstallSupported: true} + } + command := strings.TrimSpace(server.Command) + args := make([]string, 0, len(server.Args)) + for _, arg := range server.Args { + if v := strings.TrimSpace(arg); v != "" { + args = append(args, v) + } + } + base := filepath.Base(command) + switch base { + case "npx": + return mcpInstallSpec{Installer: "npm", Package: firstNonFlagArg(args), AutoInstallSupported: firstNonFlagArg(args) != ""} + case "uvx": + pkgName := firstNonFlagArg(args) + return mcpInstallSpec{Installer: "uv", Package: pkgName, AutoInstallSupported: pkgName != ""} + case "bunx": + pkgName := firstNonFlagArg(args) + return mcpInstallSpec{Installer: "bun", Package: pkgName, AutoInstallSupported: pkgName != ""} + case "python", "python3": + if len(args) >= 2 && args[0] == "-m" { + return mcpInstallSpec{Installer: "pip", Package: strings.TrimSpace(args[1]), AutoInstallSupported: false} + } + } + return mcpInstallSpec{} +} + +func firstNonFlagArg(args []string) string { + for _, arg := range args { + item := strings.TrimSpace(arg) + if item == "" || strings.HasPrefix(item, "-") { + continue + } + return item + } + return "" +} + func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) { if !s.checkAuth(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) @@ -634,7 +753,8 @@ func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) { return } var body struct { - Package string `json:"package"` + Package string `json:"package"` + Installer string `json:"installer"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) @@ -645,7 +765,7 @@ func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) { http.Error(w, "package required", http.StatusBadRequest) return } - out, binName, binPath, err := ensureMCPPackageInstalled(r.Context(), pkgName) + out, binName, binPath, err := ensureMCPPackageInstalledWithInstaller(r.Context(), pkgName, body.Installer) if err != nil { msg := err.Error() if strings.TrimSpace(out) != "" { @@ -1766,38 +1886,84 @@ func ensureClawHubReady(ctx context.Context) (string, error) { } func ensureMCPPackageInstalled(ctx context.Context, pkgName string) (output string, binName string, binPath string, err error) { + return ensureMCPPackageInstalledWithInstaller(ctx, pkgName, "npm") +} + +func ensureMCPPackageInstalledWithInstaller(ctx context.Context, pkgName, installer string) (output string, binName string, binPath string, err error) { pkgName = strings.TrimSpace(pkgName) if pkgName == "" { return "", "", "", fmt.Errorf("package empty") } + installer = strings.ToLower(strings.TrimSpace(installer)) + if installer == "" { + installer = "npm" + } outs := make([]string, 0, 4) - nodeOut, err := ensureNodeRuntime(ctx) - if nodeOut != "" { - outs = append(outs, nodeOut) - } - if err != nil { - return strings.Join(outs, "\n"), "", "", err - } - installOut, err := runInstallCommand(ctx, "npm i -g "+shellEscapeArg(pkgName)) - if installOut != "" { - outs = append(outs, installOut) - } - if err != nil { - return strings.Join(outs, "\n"), "", "", err - } - binName, err = resolveNpmPackageBin(ctx, pkgName) - if err != nil { - return strings.Join(outs, "\n"), "", "", err + switch installer { + case "npm": + nodeOut, err := ensureNodeRuntime(ctx) + if nodeOut != "" { + outs = append(outs, nodeOut) + } + if err != nil { + return strings.Join(outs, "\n"), "", "", err + } + installOut, err := runInstallCommand(ctx, "npm i -g "+shellEscapeArg(pkgName)) + if installOut != "" { + outs = append(outs, installOut) + } + if err != nil { + return strings.Join(outs, "\n"), "", "", err + } + binName, err = resolveNpmPackageBin(ctx, pkgName) + if err != nil { + return strings.Join(outs, "\n"), "", "", err + } + case "uv": + if !commandExists("uv") { + return "", "", "", fmt.Errorf("uv is not installed; install uv first to auto-install %s", pkgName) + } + installOut, err := runInstallCommand(ctx, "uv tool install "+shellEscapeArg(pkgName)) + if installOut != "" { + outs = append(outs, installOut) + } + if err != nil { + return strings.Join(outs, "\n"), "", "", err + } + binName = guessSimpleCommandName(pkgName) + case "bun": + if !commandExists("bun") { + return "", "", "", fmt.Errorf("bun is not installed; install bun first to auto-install %s", pkgName) + } + installOut, err := runInstallCommand(ctx, "bun add -g "+shellEscapeArg(pkgName)) + if installOut != "" { + outs = append(outs, installOut) + } + if err != nil { + return strings.Join(outs, "\n"), "", "", err + } + binName = guessSimpleCommandName(pkgName) + default: + return "", "", "", fmt.Errorf("unsupported installer: %s", installer) } binPath = resolveInstalledBinary(ctx, binName) if strings.TrimSpace(binPath) == "" { return strings.Join(outs, "\n"), binName, "", fmt.Errorf("installed %s but binary %q not found in PATH", pkgName, binName) } - outs = append(outs, fmt.Sprintf("installed %s", pkgName)) + outs = append(outs, fmt.Sprintf("installed %s via %s", pkgName, installer)) outs = append(outs, fmt.Sprintf("resolved binary: %s", binPath)) return strings.Join(outs, "\n"), binName, binPath, nil } +func guessSimpleCommandName(pkgName string) string { + pkgName = strings.TrimSpace(pkgName) + pkgName = strings.TrimPrefix(pkgName, "@") + if idx := strings.LastIndex(pkgName, "/"); idx >= 0 { + pkgName = pkgName[idx+1:] + } + return strings.TrimSpace(pkgName) +} + func resolveNpmPackageBin(ctx context.Context, pkgName string) (string, error) { cctx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() diff --git a/pkg/config/config.go b/pkg/config/config.go index 721e2fd..3193f31 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -329,9 +329,11 @@ type FilesystemConfig struct{} type MCPServerConfig struct { Enabled bool `json:"enabled"` Transport string `json:"transport"` + URL string `json:"url,omitempty"` Command string `json:"command"` Args []string `json:"args,omitempty"` Env map[string]string `json:"env,omitempty"` + Permission string `json:"permission,omitempty"` WorkingDir string `json:"working_dir,omitempty"` Description string `json:"description,omitempty"` Package string `json:"package,omitempty"` diff --git a/pkg/config/validate.go b/pkg/config/validate.go index bd9efe0..c5632f3 100644 --- a/pkg/config/validate.go +++ b/pkg/config/validate.go @@ -234,14 +234,36 @@ func validateMCPTools(cfg *Config) []error { if transport == "" { transport = "stdio" } - if transport != "stdio" { - errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.transport must be 'stdio'", name)) + if transport != "stdio" && transport != "http" && transport != "streamable_http" && transport != "sse" { + errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.transport must be one of: stdio, http, streamable_http, sse", name)) } - if strings.TrimSpace(server.Command) == "" { - errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.command is required when enabled=true", name)) + if transport == "stdio" && strings.TrimSpace(server.Command) == "" { + errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.command is required when transport=stdio", name)) } - if wd := strings.TrimSpace(server.WorkingDir); wd != "" && !filepath.IsAbs(wd) { - errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.working_dir must be an absolute path", name)) + if (transport == "http" || transport == "streamable_http" || transport == "sse") && strings.TrimSpace(server.URL) == "" { + errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.url is required when transport=%s", name, transport)) + } + permission := strings.ToLower(strings.TrimSpace(server.Permission)) + if permission == "" { + permission = "workspace" + } + if permission != "workspace" && permission != "full" { + errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.permission must be one of: workspace, full", name)) + } + if transport == "stdio" { + if wd := strings.TrimSpace(server.WorkingDir); wd != "" { + if permission == "full" { + if !filepath.IsAbs(wd) { + errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.working_dir must be an absolute path when permission=full", name)) + } + } else if filepath.IsAbs(wd) { + workspace := cfg.WorkspacePath() + rel, err := filepath.Rel(workspace, wd) + if err != nil || strings.HasPrefix(rel, "..") { + errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.working_dir must stay within workspace unless permission=full", name)) + } + } + } } } return errs diff --git a/pkg/tools/mcp.go b/pkg/tools/mcp.go index 9813526..f0a66bd 100644 --- a/pkg/tools/mcp.go +++ b/pkg/tools/mcp.go @@ -8,6 +8,7 @@ import ( "fmt" "hash/fnv" "io" + "net/http" "net/url" "os" "os/exec" @@ -39,6 +40,12 @@ type MCPRemoteTool struct { parameters map[string]interface{} } +type mcpRPCClient interface { + listAll(ctx context.Context, method, field string) (map[string]interface{}, error) + request(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) + Close() error +} + func NewMCPTool(workspace string, cfg config.MCPToolsConfig) *MCPTool { if cfg.RequestTimeoutSec <= 0 { cfg.RequestTimeoutSec = 20 @@ -54,7 +61,7 @@ func (t *MCPTool) Name() string { } func (t *MCPTool) Description() string { - return "Call configured MCP servers over stdio. Supports listing servers, tools, resources, prompts, and invoking remote MCP tools." + return "Call configured MCP servers over stdio or HTTP transports. Supports listing servers, tools, resources, prompts, and invoking remote MCP tools." } func (t *MCPTool) Parameters() map[string]interface{} { @@ -119,7 +126,7 @@ func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) (str callCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - client, err := newMCPStdioClient(callCtx, t.workspace, serverName, serverCfg) + client, err := newMCPClient(callCtx, t.workspace, serverName, serverCfg) if err != nil { return "", err } @@ -201,7 +208,7 @@ func (t *MCPTool) DiscoverTools(ctx context.Context) []Tool { seen := map[string]int{} for _, serverName := range names { serverCfg := t.cfg.Servers[serverName] - client, err := newMCPStdioClient(ctx, t.workspace, serverName, serverCfg) + client, err := newMCPClient(ctx, t.workspace, serverName, serverCfg) if err != nil { continue } @@ -249,7 +256,7 @@ func (t *MCPTool) callServerTool(ctx context.Context, serverName, remoteToolName } callCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() - client, err := newMCPStdioClient(callCtx, t.workspace, serverName, serverCfg) + client, err := newMCPClient(callCtx, t.workspace, serverName, serverCfg) if err != nil { return "", err } @@ -268,6 +275,8 @@ func (t *MCPTool) listServers() string { type item struct { Name string `json:"name"` Transport string `json:"transport"` + URL string `json:"url,omitempty"` + Permission string `json:"permission,omitempty"` Command string `json:"command"` WorkingDir string `json:"working_dir,omitempty"` Description string `json:"description,omitempty"` @@ -286,9 +295,15 @@ func (t *MCPTool) listServers() string { if transport == "" { transport = "stdio" } + permission := strings.TrimSpace(server.Permission) + if permission == "" { + permission = "workspace" + } items = append(items, item{ Name: name, Transport: transport, + URL: strings.TrimSpace(server.URL), + Permission: permission, Command: server.Command, WorkingDir: server.WorkingDir, Description: server.Description, @@ -326,6 +341,7 @@ func (t *MCPRemoteTool) CatalogEntry() map[string]interface{} { type mcpClient struct { workspace string + workingDir string serverName string cmd *exec.Cmd stdin io.WriteCloser @@ -355,14 +371,35 @@ type mcpResponseWaiter struct { ch chan mcpInbound } +func newMCPClient(ctx context.Context, workspace, serverName string, cfg config.MCPServerConfig) (mcpRPCClient, error) { + transport := strings.ToLower(strings.TrimSpace(cfg.Transport)) + if transport == "" { + transport = "stdio" + } + switch transport { + case "stdio": + return newMCPStdioClient(ctx, workspace, serverName, cfg) + case "sse": + return newMCPSSEClient(ctx, workspace, serverName, cfg) + case "http", "streamable_http": + return newMCPHTTPClient(ctx, serverName, cfg) + default: + return nil, fmt.Errorf("unsupported mcp transport %q", transport) + } +} + func newMCPStdioClient(ctx context.Context, workspace, serverName string, cfg config.MCPServerConfig) (*mcpClient, error) { command := strings.TrimSpace(cfg.Command) if command == "" { return nil, fmt.Errorf("mcp server %q command is empty", serverName) } + workingDir, err := resolveMCPWorkingDir(workspace, cfg) + if err != nil { + return nil, err + } cmd := exec.CommandContext(ctx, command, cfg.Args...) cmd.Env = buildMCPEnv(cfg.Env) - cmd.Dir = resolveMCPWorkingDir(workspace, cfg.WorkingDir) + cmd.Dir = workingDir stdin, err := cmd.StdinPipe() if err != nil { @@ -374,6 +411,7 @@ func newMCPStdioClient(ctx context.Context, workspace, serverName string, cfg co } client := &mcpClient{ workspace: workspace, + workingDir: workingDir, serverName: serverName, cmd: cmd, stdin: stdin, @@ -391,6 +429,450 @@ func newMCPStdioClient(ctx context.Context, workspace, serverName string, cfg co return client, nil } +type mcpHTTPClient struct { + serverName string + baseURL string + client *http.Client + nextID atomic.Int64 +} + +type mcpSSEClient struct { + workspace string + serverName string + baseURL string + endpointURL string + client *http.Client + cancel context.CancelFunc + respBody io.ReadCloser + + writeMu sync.Mutex + waiters sync.Map + nextID atomic.Int64 + + endpointOnce sync.Once + endpointCh chan string + errCh chan error +} + +func newMCPHTTPClient(ctx context.Context, serverName string, cfg config.MCPServerConfig) (*mcpHTTPClient, error) { + baseURL := strings.TrimSpace(cfg.URL) + if baseURL == "" { + return nil, fmt.Errorf("mcp server %q url is empty", serverName) + } + client := &mcpHTTPClient{ + serverName: serverName, + baseURL: baseURL, + client: &http.Client{Timeout: 30 * time.Second}, + } + if err := client.initialize(ctx); err != nil { + return nil, err + } + return client, nil +} + +func newMCPSSEClient(ctx context.Context, workspace, serverName string, cfg config.MCPServerConfig) (*mcpSSEClient, error) { + baseURL := strings.TrimSpace(cfg.URL) + if baseURL == "" { + return nil, fmt.Errorf("mcp server %q url is empty", serverName) + } + streamCtx, cancel := context.WithCancel(context.Background()) + client := &mcpSSEClient{ + workspace: workspace, + serverName: serverName, + baseURL: baseURL, + client: &http.Client{Timeout: 0}, + cancel: cancel, + endpointCh: make(chan string, 1), + errCh: make(chan error, 1), + } + req, err := http.NewRequestWithContext(streamCtx, http.MethodGet, baseURL, nil) + if err != nil { + cancel() + return nil, err + } + req.Header.Set("Accept", "text/event-stream") + resp, err := client.client.Do(req) + if err != nil { + cancel() + return nil, fmt.Errorf("connect sse for mcp server %q: %w", serverName, err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + cancel() + return nil, fmt.Errorf("connect sse for mcp server %q failed: http %d %s", serverName, resp.StatusCode, strings.TrimSpace(string(data))) + } + client.respBody = resp.Body + go client.readLoop() + select { + case endpoint := <-client.endpointCh: + client.endpointURL = endpoint + case err := <-client.errCh: + client.Close() + return nil, err + case <-ctx.Done(): + client.Close() + return nil, ctx.Err() + } + if err := client.initialize(ctx); err != nil { + client.Close() + return nil, err + } + return client, nil +} + +func (c *mcpSSEClient) Close() error { + if c == nil { + return nil + } + if c.cancel != nil { + c.cancel() + } + if c.respBody != nil { + _ = c.respBody.Close() + } + return nil +} + +func (c *mcpSSEClient) initialize(ctx context.Context) error { + result, err := c.request(ctx, "initialize", map[string]interface{}{ + "protocolVersion": mcpProtocolVersion, + "capabilities": map[string]interface{}{ + "roots": map[string]interface{}{ + "listChanged": false, + }, + }, + "clientInfo": map[string]interface{}{ + "name": "clawgo", + "version": "dev", + }, + }) + if err != nil { + return err + } + if _, ok := result["protocolVersion"]; !ok { + return fmt.Errorf("mcp server %q initialize missing protocolVersion", c.serverName) + } + return c.notify("notifications/initialized", map[string]interface{}{}) +} + +func (c *mcpSSEClient) listAll(ctx context.Context, method, field string) (map[string]interface{}, error) { + items := make([]interface{}, 0) + cursor := "" + for { + params := map[string]interface{}{} + if strings.TrimSpace(cursor) != "" { + params["cursor"] = cursor + } + result, err := c.request(ctx, method, params) + if err != nil { + return nil, err + } + batch, _ := result[field].([]interface{}) + items = append(items, batch...) + next, _ := result["nextCursor"].(string) + if strings.TrimSpace(next) == "" { + return map[string]interface{}{field: items}, nil + } + cursor = next + } +} + +func (c *mcpSSEClient) request(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) { + id := strconv.FormatInt(c.nextID.Add(1), 10) + waiter := &mcpResponseWaiter{ch: make(chan mcpInbound, 1)} + c.waiters.Store(id, waiter) + defer c.waiters.Delete(id) + if err := c.postMessage(map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); err != nil { + return nil, err + } + select { + case resp := <-waiter.ch: + if resp.Error != nil { + return nil, fmt.Errorf("mcp %s %s failed: %s", c.serverName, method, resp.Error.Message) + } + var out map[string]interface{} + if len(resp.Result) == 0 { + return map[string]interface{}{}, nil + } + if err := json.Unmarshal(resp.Result, &out); err != nil { + return nil, fmt.Errorf("decode mcp %s %s result: %w", c.serverName, method, err) + } + return out, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (c *mcpSSEClient) notify(method string, params map[string]interface{}) error { + return c.postMessage(map[string]interface{}{ + "jsonrpc": "2.0", + "method": method, + "params": params, + }) +} + +func (c *mcpSSEClient) postMessage(payload map[string]interface{}) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + c.writeMu.Lock() + defer c.writeMu.Unlock() + req, err := http.NewRequest(http.MethodPost, c.endpointURL, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("mcp %s post failed: http %d %s", c.serverName, resp.StatusCode, strings.TrimSpace(string(body))) + } + return nil +} + +func (c *mcpSSEClient) readLoop() { + reader := bufio.NewReader(c.respBody) + var eventName string + var dataLines []string + emit := func() bool { + if len(dataLines) == 0 && strings.TrimSpace(eventName) == "" { + eventName = "" + dataLines = nil + return true + } + event := strings.TrimSpace(eventName) + if event == "" { + event = "message" + } + data := strings.Join(dataLines, "\n") + if err := c.handleSSEEvent(event, data); err != nil { + c.signalErr(err) + return false + } + eventName = "" + dataLines = nil + return true + } + for { + line, err := reader.ReadString('\n') + if err != nil { + if err != io.EOF { + c.signalErr(err) + } + return + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + if !emit() { + return + } + continue + } + if strings.HasPrefix(line, ":") { + continue + } + if strings.HasPrefix(line, "event:") { + eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + continue + } + if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } +} + +func (c *mcpSSEClient) handleSSEEvent(eventName, data string) error { + switch eventName { + case "endpoint": + endpoint := strings.TrimSpace(data) + if endpoint == "" { + return fmt.Errorf("mcp server %q sent empty endpoint event", c.serverName) + } + resolved, err := resolveRelativeURL(c.baseURL, endpoint) + if err != nil { + return err + } + c.endpointOnce.Do(func() { + c.endpointURL = resolved + c.endpointCh <- resolved + }) + return nil + case "message": + var msg mcpInbound + if err := json.Unmarshal([]byte(data), &msg); err != nil { + return err + } + if msg.Method != "" && msg.ID != nil { + return c.handleServerRequest(msg) + } + if msg.Method != "" { + return nil + } + if key, ok := normalizeMCPID(msg.ID); ok { + if raw, ok := c.waiters.Load(key); ok { + raw.(*mcpResponseWaiter).ch <- msg + } + } + return nil + default: + return nil + } +} + +func (c *mcpSSEClient) handleServerRequest(msg mcpInbound) error { + method := strings.TrimSpace(msg.Method) + switch method { + case "roots/list": + root := resolveMCPDefaultRoot(c.workspace) + return c.postMessage(map[string]interface{}{ + "jsonrpc": "2.0", + "id": msg.ID, + "result": map[string]interface{}{ + "roots": []map[string]interface{}{ + {"uri": fileURI(root), "name": filepath.Base(root)}, + }, + }, + }) + case "ping": + return c.postMessage(map[string]interface{}{ + "jsonrpc": "2.0", + "id": msg.ID, + "result": map[string]interface{}{}, + }) + default: + return c.postMessage(map[string]interface{}{ + "jsonrpc": "2.0", + "id": msg.ID, + "error": map[string]interface{}{ + "code": -32601, + "message": "method not supported by clawgo mcp client", + }, + }) + } +} + +func (c *mcpSSEClient) signalErr(err error) { + select { + case c.errCh <- err: + default: + } + c.waiters.Range(func(_, value interface{}) bool { + value.(*mcpResponseWaiter).ch <- mcpInbound{ + Error: &mcpResponseError{Message: err.Error()}, + } + return true + }) +} + +func resolveRelativeURL(baseURL, ref string) (string, error) { + base, err := url.Parse(baseURL) + if err != nil { + return "", err + } + target, err := url.Parse(ref) + if err != nil { + return "", err + } + return base.ResolveReference(target).String(), nil +} + +func (c *mcpHTTPClient) Close() error { return nil } + +func (c *mcpHTTPClient) initialize(ctx context.Context) error { + result, err := c.request(ctx, "initialize", map[string]interface{}{ + "protocolVersion": mcpProtocolVersion, + "capabilities": map[string]interface{}{}, + "clientInfo": map[string]interface{}{ + "name": "clawgo", + "version": "dev", + }, + }) + if err != nil { + return err + } + if _, ok := result["protocolVersion"]; !ok { + return fmt.Errorf("mcp server %q initialize missing protocolVersion", c.serverName) + } + _, _ = c.request(ctx, "notifications/initialized", map[string]interface{}{}) + return nil +} + +func (c *mcpHTTPClient) listAll(ctx context.Context, method, field string) (map[string]interface{}, error) { + items := make([]interface{}, 0) + cursor := "" + for { + params := map[string]interface{}{} + if strings.TrimSpace(cursor) != "" { + params["cursor"] = cursor + } + result, err := c.request(ctx, method, params) + if err != nil { + return nil, err + } + batch, _ := result[field].([]interface{}) + items = append(items, batch...) + next, _ := result["nextCursor"].(string) + if strings.TrimSpace(next) == "" { + return map[string]interface{}{field: items}, nil + } + cursor = next + } +} + +func (c *mcpHTTPClient) request(ctx context.Context, method string, params map[string]interface{}) (map[string]interface{}, error) { + id := strconv.FormatInt(c.nextID.Add(1), 10) + payload := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("mcp %s %s failed: %w", c.serverName, method, err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("mcp %s %s failed: http %d %s", c.serverName, method, resp.StatusCode, strings.TrimSpace(string(data))) + } + var msg mcpInbound + if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil { + return nil, fmt.Errorf("decode mcp %s %s result: %w", c.serverName, method, err) + } + if msg.Error != nil { + return nil, fmt.Errorf("mcp %s %s failed: %s", c.serverName, method, msg.Error.Message) + } + if len(msg.Result) == 0 { + return map[string]interface{}{}, nil + } + var out map[string]interface{} + if err := json.Unmarshal(msg.Result, &out); err != nil { + return nil, fmt.Errorf("decode mcp %s %s result: %w", c.serverName, method, err) + } + return out, nil +} + func (c *mcpClient) Close() error { if c == nil || c.cmd == nil { return nil @@ -536,11 +1018,15 @@ func (c *mcpClient) handleServerRequest(msg mcpInbound) error { method := strings.TrimSpace(msg.Method) switch method { case "roots/list": + rootDir := c.workingDir + if strings.TrimSpace(rootDir) == "" { + rootDir = resolveMCPDefaultRoot(c.workspace) + } return c.reply(msg.ID, map[string]interface{}{ "roots": []map[string]interface{}{ { - "uri": fileURI(resolveMCPWorkingDir(c.workspace, "")), - "name": filepath.Base(resolveMCPWorkingDir(c.workspace, "")), + "uri": fileURI(rootDir), + "name": filepath.Base(rootDir), }, }, }) @@ -631,11 +1117,34 @@ func buildMCPEnv(overrides map[string]string) []string { return env } -func resolveMCPWorkingDir(workspace, wd string) string { - wd = strings.TrimSpace(wd) - if wd != "" { - return wd +func resolveMCPWorkingDir(workspace string, cfg config.MCPServerConfig) (string, error) { + root := resolveMCPDefaultRoot(workspace) + permission := strings.ToLower(strings.TrimSpace(cfg.Permission)) + if permission == "" { + permission = "workspace" } + wd := strings.TrimSpace(cfg.WorkingDir) + if wd == "" { + return root, nil + } + if permission == "full" { + if !filepath.IsAbs(wd) { + return "", fmt.Errorf("mcp server %q working_dir must be absolute when permission=full", strings.TrimSpace(cfg.Command)) + } + return filepath.Clean(wd), nil + } + if filepath.IsAbs(wd) { + clean := filepath.Clean(wd) + rel, err := filepath.Rel(root, clean) + if err != nil || strings.HasPrefix(rel, "..") { + return "", fmt.Errorf("mcp working_dir %q must stay within workspace root %q unless permission=full", clean, root) + } + return clean, nil + } + return filepath.Clean(filepath.Join(root, wd)), nil +} + +func resolveMCPDefaultRoot(workspace string) string { if abs, err := filepath.Abs(workspace); err == nil { return abs } diff --git a/pkg/tools/mcp_test.go b/pkg/tools/mcp_test.go index d589e45..b66be01 100644 --- a/pkg/tools/mcp_test.go +++ b/pkg/tools/mcp_test.go @@ -6,7 +6,10 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "net/http/httptest" "os" + "path/filepath" "strconv" "strings" "testing" @@ -290,9 +293,9 @@ func TestValidateMCPTools(t *testing.T) { cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{ "bad": { Enabled: true, - Transport: "http", + Transport: "ws", Command: "", - WorkingDir: "relative", + WorkingDir: "/outside-workspace", }, } errs := config.Validate(cfg) @@ -306,9 +309,7 @@ func TestValidateMCPTools(t *testing.T) { joined := strings.Join(got, "\n") for _, want := range []string{ "tools.mcp.request_timeout_sec must be > 0 when tools.mcp.enabled=true", - "tools.mcp.servers.bad.transport must be 'stdio'", - "tools.mcp.servers.bad.command is required when enabled=true", - "tools.mcp.servers.bad.working_dir must be an absolute path", + "tools.mcp.servers.bad.transport must be one of: stdio, http, streamable_http, sse", } { if !strings.Contains(joined, want) { t.Fatalf("expected validation error %q in:\n%s", want, joined) @@ -316,6 +317,31 @@ func TestValidateMCPTools(t *testing.T) { } } +func TestValidateMCPToolsFullPermissionRequiresAbsolutePath(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{ + "full": { + Enabled: true, + Transport: "stdio", + Command: "demo", + Permission: "full", + WorkingDir: "relative", + }, + } + errs := config.Validate(cfg) + if len(errs) == 0 { + t.Fatal("expected validation errors") + } + joined := "" + for _, err := range errs { + joined += err.Error() + "\n" + } + if !strings.Contains(joined, "tools.mcp.servers.full.working_dir must be an absolute path when permission=full") { + t.Fatalf("unexpected validation errors:\n%s", joined) + } +} + func TestMCPToolListTools(t *testing.T) { tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ Enabled: true, @@ -346,9 +372,219 @@ func TestMCPToolListTools(t *testing.T) { } } +func TestMCPToolHTTPTransport(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + method, _ := req["method"].(string) + id := req["id"] + var resp map[string]interface{} + switch method { + case "initialize": + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{ + "protocolVersion": mcpProtocolVersion, + }, + } + case "notifications/initialized": + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{}, + } + case "tools/list": + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{ + "tools": []map[string]interface{}{ + { + "name": "echo", + "description": "Echo text", + "inputSchema": map[string]interface{}{"type": "object"}, + }, + }, + }, + } + case "tools/call": + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{ + "content": []map[string]interface{}{ + {"type": "text", "text": "echo:http"}, + }, + }, + } + default: + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]interface{}{"code": -32601, "message": "unsupported"}, + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ + Enabled: true, + RequestTimeoutSec: 5, + Servers: map[string]config.MCPServerConfig{ + "httpdemo": { + Enabled: true, + Transport: "http", + URL: server.URL, + }, + }, + }) + out, err := tool.Execute(context.Background(), map[string]interface{}{ + "action": "list_tools", + "server": "httpdemo", + }) + if err != nil { + t.Fatalf("http list_tools returned error: %v", err) + } + if !strings.Contains(out, `"name": "echo"`) { + t.Fatalf("expected http tool listing, got: %s", out) + } +} + +func TestMCPToolSSETransport(t *testing.T) { + messageCh := make(chan string, 8) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/sse": + w.Header().Set("Content-Type", "text/event-stream") + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "flush unsupported", http.StatusInternalServerError) + return + } + _, _ = fmt.Fprintf(w, "event: endpoint\n") + _, _ = fmt.Fprintf(w, "data: /messages\n\n") + flusher.Flush() + notify := r.Context().Done() + for { + select { + case payload := <-messageCh: + _, _ = fmt.Fprintf(w, "event: message\n") + _, _ = fmt.Fprintf(w, "data: %s\n\n", payload) + flusher.Flush() + case <-notify: + return + } + } + case r.Method == http.MethodPost && r.URL.Path == "/messages": + defer r.Body.Close() + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + method, _ := req["method"].(string) + id := req["id"] + switch method { + case "initialize": + payload, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{ + "protocolVersion": mcpProtocolVersion, + }, + }) + messageCh <- string(payload) + case "tools/list": + payload, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{ + "tools": []map[string]interface{}{ + {"name": "echo", "description": "Echo text", "inputSchema": map[string]interface{}{"type": "object"}}, + }, + }, + }) + messageCh <- string(payload) + case "tools/call": + payload, _ := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": "echo:sse"}}, + }, + }) + messageCh <- string(payload) + } + w.WriteHeader(http.StatusAccepted) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{ + Enabled: true, + RequestTimeoutSec: 5, + Servers: map[string]config.MCPServerConfig{ + "ssedemo": { + Enabled: true, + Transport: "sse", + URL: server.URL + "/sse", + }, + }, + }) + out, err := tool.Execute(context.Background(), map[string]interface{}{ + "action": "list_tools", + "server": "ssedemo", + }) + if err != nil { + t.Fatalf("sse list_tools returned error: %v", err) + } + if !strings.Contains(out, `"name": "echo"`) { + t.Fatalf("expected sse tool listing, got: %s", out) + } +} + func TestBuildMCPDynamicToolName(t *testing.T) { got := buildMCPDynamicToolName("Context7 Server", "resolve-library.id") if got != "mcp__context7_server__resolve_library_id" { t.Fatalf("unexpected tool name: %s", got) } } + +func TestResolveMCPWorkingDirWorkspaceScoped(t *testing.T) { + workspace := t.TempDir() + dir, err := resolveMCPWorkingDir(workspace, config.MCPServerConfig{WorkingDir: "tools/context7"}) + if err != nil { + t.Fatalf("resolveMCPWorkingDir returned error: %v", err) + } + want := filepath.Join(workspace, "tools", "context7") + if dir != want { + t.Fatalf("unexpected working dir: got %q want %q", dir, want) + } +} + +func TestResolveMCPWorkingDirRejectsOutsideWorkspaceWithoutFullPermission(t *testing.T) { + workspace := t.TempDir() + _, err := resolveMCPWorkingDir(workspace, config.MCPServerConfig{WorkingDir: "/"}) + if err == nil { + t.Fatal("expected outside-workspace path to be rejected") + } +} + +func TestResolveMCPWorkingDirAllowsAbsolutePathWithFullPermission(t *testing.T) { + dir, err := resolveMCPWorkingDir(t.TempDir(), config.MCPServerConfig{Permission: "full", WorkingDir: "/"}) + if err != nil { + t.Fatalf("resolveMCPWorkingDir returned error: %v", err) + } + if dir != "/" { + t.Fatalf("unexpected working dir: %q", dir) + } +} diff --git a/pkg/tools/skill_exec.go b/pkg/tools/skill_exec.go index f03167d..23237be 100644 --- a/pkg/tools/skill_exec.go +++ b/pkg/tools/skill_exec.go @@ -62,13 +62,23 @@ func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{} skill, _ := args["skill"].(string) script, _ := args["script"].(string) reason, _ := args["reason"].(string) + callerAgent, _ := args["caller_agent"].(string) + callerScope, _ := args["caller_scope"].(string) reason = strings.TrimSpace(reason) if reason == "" { reason = "unspecified" } + callerAgent = strings.TrimSpace(callerAgent) + if callerAgent == "" { + callerAgent = "main" + } + callerScope = strings.TrimSpace(callerScope) + if callerScope == "" { + callerScope = "main_agent" + } if strings.TrimSpace(skill) == "" || strings.TrimSpace(script) == "" { err := fmt.Errorf("skill and script are required") - t.writeAudit(skill, script, reason, false, err.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } if strings.TrimSpace(t.workspace) != "" { @@ -77,31 +87,31 @@ func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{} skillDir, err := t.resolveSkillDir(skill) if err != nil { - t.writeAudit(skill, script, reason, false, err.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } if _, err := os.Stat(filepath.Join(skillDir, "SKILL.md")); err != nil { err = fmt.Errorf("SKILL.md missing for skill: %s", skill) - t.writeAudit(skill, script, reason, false, err.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } relScript := filepath.Clean(script) if strings.Contains(relScript, "..") || filepath.IsAbs(relScript) { err := fmt.Errorf("script must be relative path inside skill directory") - t.writeAudit(skill, script, reason, false, err.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } if !strings.HasPrefix(relScript, "scripts"+string(os.PathSeparator)) { err := fmt.Errorf("script must be under scripts/ directory") - t.writeAudit(skill, script, reason, false, err.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } scriptPath := filepath.Join(skillDir, relScript) if _, err := os.Stat(scriptPath); err != nil { err = fmt.Errorf("script not found: %s", scriptPath) - t.writeAudit(skill, script, reason, false, err.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } @@ -124,7 +134,7 @@ func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{} for attempt := 0; attempt <= policy.MaxRestarts; attempt++ { cmd, err := buildSkillCommand(ctx, scriptPath, cmdArgs) if err != nil { - t.writeAudit(skill, script, reason, false, err.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, err.Error()) return "", err } cmd.Dir = skillDir @@ -158,7 +168,7 @@ func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{} } output := merged.String() if runErr != nil { - t.writeAudit(skill, script, reason, false, runErr.Error()) + t.writeAudit(skill, script, reason, callerAgent, callerScope, false, runErr.Error()) return "", fmt.Errorf("skill execution failed: %w\n%s", runErr, output) } @@ -166,21 +176,23 @@ func (t *SkillExecTool) Execute(ctx context.Context, args map[string]interface{} if out == "" { out = "(no output)" } - t.writeAudit(skill, script, reason, true, "") + t.writeAudit(skill, script, reason, callerAgent, callerScope, true, "") return out, nil } -func (t *SkillExecTool) writeAudit(skill, script, reason string, ok bool, errText string) { +func (t *SkillExecTool) writeAudit(skill, script, reason, callerAgent, callerScope string, ok bool, errText string) { if strings.TrimSpace(t.workspace) == "" { return } memDir := filepath.Join(t.workspace, "memory") _ = os.MkdirAll(memDir, 0755) - row := fmt.Sprintf("{\"time\":%q,\"skill\":%q,\"script\":%q,\"reason\":%q,\"ok\":%t,\"error\":%q}\n", + row := fmt.Sprintf("{\"time\":%q,\"skill\":%q,\"script\":%q,\"reason\":%q,\"caller_agent\":%q,\"caller_scope\":%q,\"ok\":%t,\"error\":%q}\n", time.Now().UTC().Format(time.RFC3339), strings.TrimSpace(skill), strings.TrimSpace(script), strings.TrimSpace(reason), + strings.TrimSpace(callerAgent), + strings.TrimSpace(callerScope), ok, strings.TrimSpace(errText), ) diff --git a/pkg/tools/skill_exec_test.go b/pkg/tools/skill_exec_test.go new file mode 100644 index 0000000..77dae9b --- /dev/null +++ b/pkg/tools/skill_exec_test.go @@ -0,0 +1,27 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSkillExecWriteAuditIncludesCallerIdentity(t *testing.T) { + workspace := t.TempDir() + tool := NewSkillExecTool(workspace) + + tool.writeAudit("demo", "scripts/run.sh", "test", "coder", "subagent", true, "") + + data, err := os.ReadFile(filepath.Join(workspace, "memory", "skill-audit.jsonl")) + if err != nil { + t.Fatalf("read audit file: %v", err) + } + text := string(data) + if !strings.Contains(text, `"caller_agent":"coder"`) { + t.Fatalf("expected caller_agent in audit row, got: %s", text) + } + if !strings.Contains(text, `"caller_scope":"subagent"`) { + t.Fatalf("expected caller_scope in audit row, got: %s", text) + } +} diff --git a/pkg/tools/tool_allowlist_groups.go b/pkg/tools/tool_allowlist_groups.go index dddb425..deee8e7 100644 --- a/pkg/tools/tool_allowlist_groups.go +++ b/pkg/tools/tool_allowlist_groups.go @@ -49,6 +49,12 @@ var defaultToolAllowlistGroups = []ToolAllowlistGroup{ Aliases: []string{"subagent", "agent_runtime"}, Tools: []string{"spawn", "subagents", "subagent_profile"}, }, + { + Name: "skills", + Description: "Skill script execution tools", + Aliases: []string{"skill", "skill_scripts"}, + Tools: []string{"skill_exec"}, + }, } func ToolAllowlistGroups() []ToolAllowlistGroup { diff --git a/pkg/tools/tool_allowlist_groups_test.go b/pkg/tools/tool_allowlist_groups_test.go index f2b7212..02dabcb 100644 --- a/pkg/tools/tool_allowlist_groups_test.go +++ b/pkg/tools/tool_allowlist_groups_test.go @@ -17,7 +17,7 @@ func TestExpandToolAllowlistEntries_GroupPrefix(t *testing.T) { } func TestExpandToolAllowlistEntries_BareGroupAndAlias(t *testing.T) { - got := ExpandToolAllowlistEntries([]string{"memory_all", "@subagents"}) + got := ExpandToolAllowlistEntries([]string{"memory_all", "@subagents", "skill"}) contains := map[string]bool{} for _, item := range got { contains[item] = true @@ -28,4 +28,7 @@ func TestExpandToolAllowlistEntries_BareGroupAndAlias(t *testing.T) { if !contains["spawn"] || !contains["subagents"] || !contains["subagent_profile"] { t.Fatalf("subagents alias expansion missing subagent tools: %v", got) } + if !contains["skill_exec"] { + t.Fatalf("skills alias expansion missing skill_exec: %v", got) + } } diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index 981ce1e..fd03de8 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -312,6 +312,9 @@ const resources = { configMCPInstallDoneTitle: 'MCP package installed', configMCPInstallDoneMessage: 'Installed {{package}} and resolved binary {{bin}}.', configMCPInstallDoneFallback: 'MCP package installed.', + configMCPArgsEnterHint: 'Type one argument and press Enter', + configMCPCommandMissing: 'MCP command is unavailable.', + configMCPInstallSuggested: 'Suggested package: {{pkg}}', configMCPDiscoveredTools: 'Discovered MCP Tools', configMCPDiscoveredToolsCount: '{{count}} discovered', configNoMCPDiscoveredTools: 'No MCP tools discovered yet.', @@ -495,6 +498,7 @@ const resources = { dingtalk: 'DingTalk', filesystem: 'Filesystem', working_dir: 'Working Directory', + url: 'URL', timeout: 'Timeout', auto_install_missing: 'Auto-install Missing', sandbox: 'Sandbox', @@ -833,6 +837,9 @@ const resources = { configMCPInstallDoneTitle: 'MCP 包安装完成', configMCPInstallDoneMessage: '已安装 {{package}},并解析到可执行文件 {{bin}}。', configMCPInstallDoneFallback: 'MCP 包已安装。', + configMCPArgsEnterHint: '输入一个参数后按回车添加', + configMCPCommandMissing: 'MCP 命令不可用。', + configMCPInstallSuggested: '建议安装包:{{pkg}}', configMCPDiscoveredTools: '已发现的 MCP 工具', configMCPDiscoveredToolsCount: '已发现 {{count}} 个', configNoMCPDiscoveredTools: '暂未发现 MCP 工具。', @@ -1016,6 +1023,7 @@ const resources = { dingtalk: '钉钉', filesystem: '文件系统', working_dir: '工作目录', + url: '地址', timeout: '超时', auto_install_missing: '自动安装缺失依赖', sandbox: '沙箱', diff --git a/webui/src/pages/MCP.tsx b/webui/src/pages/MCP.tsx index 9c0f1e5..ebccc99 100644 --- a/webui/src/pages/MCP.tsx +++ b/webui/src/pages/MCP.tsx @@ -23,6 +23,8 @@ const MCP: React.FC = () => { const ui = useUI(); const [newMCPServerName, setNewMCPServerName] = useState(''); const [mcpTools, setMcpTools] = useState>([]); + const [mcpServerChecks, setMcpServerChecks] = useState>([]); + const [argInputs, setArgInputs] = useState>({}); const [baseline, setBaseline] = useState(null); const currentPayload = useMemo(() => cfg || {}, [cfg]); @@ -49,9 +51,13 @@ const MCP: React.FC = () => { const data = await r.json(); if (!cancelled) { setMcpTools(Array.isArray(data?.mcp_tools) ? data.mcp_tools : []); + setMcpServerChecks(Array.isArray(data?.mcp_server_checks) ? data.mcp_server_checks : []); } } catch { - if (!cancelled) setMcpTools([]); + if (!cancelled) { + setMcpTools([]); + setMcpServerChecks([]); + } } } @@ -67,6 +73,23 @@ const MCP: React.FC = () => { setCfg((v) => setPath(v, `tools.mcp.servers.${name}.${field}`, value)); } + function addMCPArg(name: string, rawValue: string) { + const value = rawValue.trim(); + if (!value) return; + const current = ((((cfg as any)?.tools?.mcp?.servers?.[name]?.args) || []) as any[]) + .map((x) => String(x).trim()) + .filter(Boolean); + updateMCPServerField(name, 'args', [...current, value]); + setArgInputs((prev) => ({ ...prev, [name]: '' })); + } + + function removeMCPArg(name: string, index: number) { + const current = ((((cfg as any)?.tools?.mcp?.servers?.[name]?.args) || []) as any[]) + .map((x) => String(x).trim()) + .filter(Boolean); + updateMCPServerField(name, 'args', current.filter((_, i) => i !== index)); + } + function addMCPServer() { const name = newMCPServerName.trim(); if (!name) return; @@ -83,9 +106,11 @@ const MCP: React.FC = () => { next.tools.mcp.servers[name] = { enabled: true, transport: 'stdio', + url: '', command: '', args: [], env: {}, + permission: 'workspace', working_dir: '', description: '', package: '', @@ -94,6 +119,7 @@ const MCP: React.FC = () => { return next; }); setNewMCPServerName(''); + setArgInputs((prev) => ({ ...prev, [name]: '' })); } async function removeMCPServer(name: string) { @@ -111,21 +137,32 @@ const MCP: React.FC = () => { } return next; }); + setArgInputs((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); } - function inferMCPPackage(server: any): string { - if (typeof server?.package === 'string' && server.package.trim()) return server.package.trim(); - const command = String(server?.command || '').trim(); - const args = Array.isArray(server?.args) ? server.args.map((x: any) => String(x).trim()).filter(Boolean) : []; - if (command === 'npx' || command.endsWith('/npx')) { - const pkg = args.find((arg: string) => !arg.startsWith('-')); - return pkg || ''; + function inferMCPInstallSpec(server: any): { installer: string; packageName: string } { + if (typeof server?.installer === 'string' && server.installer.trim() && typeof server?.package === 'string' && server.package.trim()) { + return { installer: server.installer.trim(), packageName: server.package.trim() }; } - return ''; + if (typeof server?.package === 'string' && server.package.trim()) { + return { installer: 'npm', packageName: server.package.trim() }; + } + const command = String(server?.command || '').trim().split('/').pop() || ''; + const args = Array.isArray(server?.args) ? server.args.map((x: any) => String(x).trim()).filter(Boolean) : []; + const pkg = args.find((arg: string) => !arg.startsWith('-')) || ''; + if (command === 'npx') return { installer: 'npm', packageName: pkg }; + if (command === 'uvx') return { installer: 'uv', packageName: pkg }; + if (command === 'bunx') return { installer: 'bun', packageName: pkg }; + return { installer: 'npm', packageName: '' }; } async function installMCPServerPackage(name: string, server: any) { - const defaultPkg = inferMCPPackage(server); + const inferred = inferMCPInstallSpec(server); + const defaultPkg = inferred.packageName; const pkg = await ui.promptDialog({ title: t('configMCPInstallTitle'), message: t('configMCPInstallMessage', { name }), @@ -141,7 +178,7 @@ const MCP: React.FC = () => { const r = await fetch(`/webui/api/mcp/install${q}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ package: packageName }), + body: JSON.stringify({ package: packageName, installer: inferred.installer }), }); const text = await r.text(); if (!r.ok) { @@ -172,6 +209,14 @@ const MCP: React.FC = () => { } } + async function installMCPServerCheckPackage(check: { name: string; package?: string; installer?: string }) { + const server = (((cfg as any)?.tools?.mcp?.servers?.[check.name]) || {}) as any; + if (check.package && !String(server?.package || '').trim()) { + updateMCPServerField(check.name, 'package', check.package); + } + await installMCPServerPackage(check.name, { ...server, package: check.package || server?.package, installer: check.installer }); + } + async function saveConfig() { try { const payload = cfg; @@ -255,22 +300,86 @@ const MCP: React.FC = () => {
- {Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record).map(([name, server]) => ( + {Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record).map(([name, server]) => { + const transport = String(server?.transport || 'stdio'); + const isStdio = transport === 'stdio'; + const usesURL = transport === 'http' || transport === 'streamable_http' || transport === 'sse'; + return (
{name}
- updateMCPServerField(name, 'command', e.target.value)} placeholder={t('configLabels.command')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - updateMCPServerField(name, 'working_dir', e.target.value)} placeholder={t('configLabels.working_dir')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - updateMCPServerField(name, 'args', e.target.value.split(',').map(s=>s.trim()).filter(Boolean))} placeholder={`${t('configLabels.args')}${t('configCommaSeparatedHint')}`} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - updateMCPServerField(name, 'package', e.target.value)} placeholder={t('configLabels.package')} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - updateMCPServerField(name, 'description', e.target.value)} placeholder={t('configLabels.description')} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> - + + {isStdio && ( + <> + updateMCPServerField(name, 'command', e.target.value)} placeholder={t('configLabels.command')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> + + updateMCPServerField(name, 'working_dir', e.target.value)} placeholder={t('configLabels.working_dir')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> +
+
+ {(Array.isArray(server?.args) ? server.args : []).map((arg: any, index: number) => ( + + {String(arg)} + + + ))} +
+ setArgInputs((prev) => ({ ...prev, [name]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + addMCPArg(name, argInputs[name] || ''); + } + }} + onBlur={() => addMCPArg(name, argInputs[name] || '')} + placeholder={t('configMCPArgsEnterHint')} + className="w-full px-2 py-1 rounded-lg bg-zinc-900/80 border border-zinc-800" + /> +
+ updateMCPServerField(name, 'package', e.target.value)} placeholder={t('configLabels.package')} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> + + )} + {usesURL && ( + updateMCPServerField(name, 'url', e.target.value)} placeholder={t('configLabels.url')} className="md:col-span-5 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> + )} + updateMCPServerField(name, 'description', e.target.value)} placeholder={t('configLabels.description')} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800" /> + {isStdio && ( + + )} + {(() => { + const check = mcpServerChecks.find((item) => item.name === name); + if (!check || check.status === 'ok' || check.status === 'disabled' || check.status === 'not_applicable') return null; + return ( +
+
+
{check.message || t('configMCPCommandMissing')}
+ {check.package && ( +
{t('configMCPInstallSuggested', { pkg: check.package })} {check.installer ? `(${check.installer})` : ''}
+ )} +
+ {check.installable && ( + + )} +
+ ); + })()}
- ))} + )})} {Object.keys((((cfg as any)?.tools?.mcp?.servers) || {}) as Record).length === 0 && (
{t('configNoMCPServers')}
)} diff --git a/webui/src/pages/SubagentProfiles.tsx b/webui/src/pages/SubagentProfiles.tsx index 6c13c3d..4a4b283 100644 --- a/webui/src/pages/SubagentProfiles.tsx +++ b/webui/src/pages/SubagentProfiles.tsx @@ -390,6 +390,9 @@ const SubagentProfiles: React.FC = () => { className="w-full px-2 py-1 text-xs bg-zinc-900 border border-zinc-700 rounded" placeholder="read_file, list_files, memory_search" /> +
+ skill_exec is inherited automatically and does not need to be listed here. +
{groups.length > 0 && (
{groups.map((g) => ( diff --git a/webui/src/pages/Subagents.tsx b/webui/src/pages/Subagents.tsx index 9c76743..7f25929 100644 --- a/webui/src/pages/Subagents.tsx +++ b/webui/src/pages/Subagents.tsx @@ -96,6 +96,13 @@ type RegistrySubagent = { prompt_file_found?: boolean; memory_namespace?: string; tool_allowlist?: string[]; + inherited_tools?: string[]; + effective_tools?: string[]; + tool_visibility?: { + mode?: string; + inherited_tool_count?: number; + effective_tool_count?: number; + }; routing_keywords?: string[]; }; @@ -539,6 +546,7 @@ const Subagents: React.FC = () => { const localMainStats = taskStats[normalizeTitle(localRoot.agent_id, 'main')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] }; const localMainTask = recentTaskByAgent[normalizeTitle(localRoot.agent_id, 'main')]; + const localMainRegistry = registryItems.find((item) => item.agent_id === localRoot.agent_id); localBranchStats.running += localMainStats.running; localBranchStats.failed += localMainStats.failed; const localMainCard: GraphCardSpec = { @@ -557,8 +565,10 @@ const Subagents: React.FC = () => { `children=${localChildren.length + remoteClusters.length}`, `total=${localMainStats.total} running=${localMainStats.running}`, `waiting=${localMainStats.waiting} failed=${localMainStats.failed}`, - `notify=${normalizeTitle(registryItems.find((item) => item.agent_id === localRoot.agent_id)?.notify_main_policy, 'final_only')}`, + `notify=${normalizeTitle(localMainRegistry?.notify_main_policy, 'final_only')}`, `transport=${normalizeTitle(localRoot.transport, 'local')} type=${normalizeTitle(localRoot.type, 'router')}`, + `tools=${normalizeTitle(localMainRegistry?.tool_visibility?.mode, 'allowlist')} visible=${localMainRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${localMainRegistry?.tool_visibility?.inherited_tool_count ?? 0}`, + (localMainRegistry?.inherited_tools || []).length ? `inherits: ${(localMainRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -', localMainStats.active[0] ? `task: ${localMainStats.active[0].title}` : t('noLiveTasks'), ], accent: localMainStats.running > 0 ? 'bg-emerald-500' : localMainStats.latestStatus === 'failed' ? 'bg-red-500' : 'bg-amber-400', @@ -575,6 +585,7 @@ const Subagents: React.FC = () => { const childY = childStartY; const stats = taskStats[normalizeTitle(child.agent_id, '')] || { total: 0, running: 0, failed: 0, waiting: 0, latestStatus: '', latestUpdated: 0, active: [] }; const task = recentTaskByAgent[normalizeTitle(child.agent_id, '')]; + const childRegistry = registryItems.find((item) => item.agent_id === child.agent_id); localBranchStats.running += stats.running; localBranchStats.failed += stats.failed; cards.push({ @@ -592,8 +603,10 @@ const Subagents: React.FC = () => { meta: [ `total=${stats.total} running=${stats.running}`, `waiting=${stats.waiting} failed=${stats.failed}`, - `notify=${normalizeTitle(registryItems.find((item) => item.agent_id === child.agent_id)?.notify_main_policy, 'final_only')}`, + `notify=${normalizeTitle(childRegistry?.notify_main_policy, 'final_only')}`, `transport=${normalizeTitle(child.transport, 'local')} type=${normalizeTitle(child.type, 'worker')}`, + `tools=${normalizeTitle(childRegistry?.tool_visibility?.mode, 'allowlist')} visible=${childRegistry?.tool_visibility?.effective_tool_count ?? 0} inherited=${childRegistry?.tool_visibility?.inherited_tool_count ?? 0}`, + (childRegistry?.inherited_tools || []).length ? `inherits: ${(childRegistry?.inherited_tools || []).join(', ')}` : 'inherits: -', stats.active[0] ? `task: ${stats.active[0].title}` : task ? `last: ${summarizeTask(task.task, task.label)}` : t('noLiveTasks'), ], accent: stats.running > 0 ? 'bg-emerald-500' : stats.latestStatus === 'failed' ? 'bg-red-500' : 'bg-sky-400',