2 Commits

Author SHA1 Message Date
lpf
bb429e3fc8 fix: send mcp initialized as notification 2026-03-08 12:11:38 +08:00
lpf
f043de5384 feat: expand mcp transports and skill execution 2026-03-08 11:08:41 +08:00
21 changed files with 1479 additions and 84 deletions

View File

@@ -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__<server>__<tool>`
- `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` 段落。

View File

@@ -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__<server>__<tool>` 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).

View File

@@ -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"
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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()

View File

@@ -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"`

View File

@@ -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

View File

@@ -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,476 @@ 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)
}
return c.notify(ctx, "notifications/initialized", map[string]interface{}{})
}
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 *mcpHTTPClient) notify(ctx context.Context, method string, params map[string]interface{}) error {
payload := map[string]interface{}{
"jsonrpc": "2.0",
"method": method,
"params": params,
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return 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 fmt.Errorf("mcp %s %s failed: http %d %s", c.serverName, method, resp.StatusCode, strings.TrimSpace(string(data)))
}
return nil
}
func (c *mcpClient) Close() error {
if c == nil || c.cmd == nil {
return nil
@@ -536,11 +1044,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 +1143,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
}

View File

@@ -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,225 @@ func TestMCPToolListTools(t *testing.T) {
}
}
func TestMCPToolHTTPTransport(t *testing.T) {
initializedNotified := false
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":
if _, hasID := req["id"]; hasID {
http.Error(w, "notification must not include id", http.StatusBadRequest)
return
}
initializedNotified = true
w.WriteHeader(http.StatusAccepted)
return
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)
}
if !initializedNotified {
t.Fatal("expected initialized notification to be sent")
}
}
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)
}
}

View File

@@ -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),
)

View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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: '沙箱',

View File

@@ -23,6 +23,8 @@ const MCP: React.FC = () => {
const ui = useUI();
const [newMCPServerName, setNewMCPServerName] = useState('');
const [mcpTools, setMcpTools] = useState<Array<{ name: string; description?: string; mcp?: { server?: string; remote_tool?: string } }>>([]);
const [mcpServerChecks, setMcpServerChecks] = useState<Array<{ name: string; status?: string; message?: string; package?: string; installer?: string; installable?: boolean; resolved?: string }>>([]);
const [argInputs, setArgInputs] = useState<Record<string, string>>({});
const [baseline, setBaseline] = useState<any>(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 = () => {
</div>
</div>
<div className="space-y-2">
{Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).map(([name, server]) => (
{Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).map(([name, server]) => {
const transport = String(server?.transport || 'stdio');
const isStdio = transport === 'stdio';
const usesURL = transport === 'http' || transport === 'streamable_http' || transport === 'sse';
return (
<div key={name} className="grid grid-cols-1 md:grid-cols-12 gap-2 rounded-xl border border-zinc-800 bg-zinc-900/30 p-2 text-xs">
<div className="md:col-span-2 font-mono text-zinc-300 flex items-center">{name}</div>
<label className="md:col-span-1 flex items-center gap-2 text-zinc-300">
<input type="checkbox" checked={!!server?.enabled} onChange={(e)=>updateMCPServerField(name, 'enabled', e.target.checked)} />
{t('enable')}
</label>
<input value={String(server?.command || '')} onChange={(e)=>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" />
<input value={String(server?.working_dir || '')} onChange={(e)=>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" />
<input value={Array.isArray(server?.args) ? server.args.join(',') : ''} onChange={(e)=>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" />
<input value={String(server?.package || '')} onChange={(e)=>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" />
<input value={String(server?.description || '')} onChange={(e)=>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" />
<button onClick={()=>installMCPServerPackage(name, server)} className="md:col-span-1 px-2 py-1 rounded bg-emerald-900/60 hover:bg-emerald-800 text-emerald-100">{t('install')}</button>
<select value={transport} onChange={(e)=>updateMCPServerField(name, 'transport', e.target.value)} className="md:col-span-2 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800">
<option value="stdio">stdio</option>
<option value="http">http</option>
<option value="streamable_http">streamable_http</option>
<option value="sse">sse</option>
</select>
{isStdio && (
<>
<input value={String(server?.command || '')} onChange={(e)=>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" />
<select value={String(server?.permission || 'workspace')} onChange={(e)=>updateMCPServerField(name, 'permission', e.target.value)} className="md:col-span-1 px-2 py-1 rounded-lg bg-zinc-950/70 border border-zinc-800">
<option value="workspace">workspace</option>
<option value="full">full</option>
</select>
<input value={String(server?.working_dir || '')} onChange={(e)=>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" />
<div className="md:col-span-2 rounded-lg bg-zinc-950/70 border border-zinc-800 p-2 space-y-2">
<div className="flex flex-wrap gap-2">
{(Array.isArray(server?.args) ? server.args : []).map((arg: any, index: number) => (
<span key={`${name}-arg-${index}`} className="inline-flex items-center gap-2 rounded-md bg-zinc-800 px-2 py-1 text-[11px] text-zinc-200">
<span className="font-mono">{String(arg)}</span>
<button type="button" onClick={() => removeMCPArg(name, index)} className="text-zinc-400 hover:text-zinc-100">x</button>
</span>
))}
</div>
<input
value={argInputs[name] || ''}
onChange={(e) => 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"
/>
</div>
<input value={String(server?.package || '')} onChange={(e)=>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 && (
<input value={String(server?.url || '')} onChange={(e)=>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" />
)}
<input value={String(server?.description || '')} onChange={(e)=>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 && (
<button onClick={()=>installMCPServerPackage(name, server)} className="md:col-span-1 px-2 py-1 rounded bg-emerald-900/60 hover:bg-emerald-800 text-emerald-100">{t('install')}</button>
)}
<button onClick={()=>removeMCPServer(name)} className="md:col-span-1 px-2 py-1 rounded bg-red-900/60 hover:bg-red-800 text-red-100">{t('delete')}</button>
{(() => {
const check = mcpServerChecks.find((item) => item.name === name);
if (!check || check.status === 'ok' || check.status === 'disabled' || check.status === 'not_applicable') return null;
return (
<div className="md:col-span-12 rounded-lg border border-amber-800/60 bg-amber-950/30 px-3 py-2 text-xs text-amber-100 flex items-center justify-between gap-3 flex-wrap">
<div>
<div>{check.message || t('configMCPCommandMissing')}</div>
{check.package && (
<div className="text-amber-300/80">{t('configMCPInstallSuggested', { pkg: check.package })} {check.installer ? `(${check.installer})` : ''}</div>
)}
</div>
{check.installable && (
<button onClick={() => installMCPServerCheckPackage(check)} className="px-2 py-1 rounded bg-amber-700 hover:bg-amber-600 text-white">
{t('install')}
</button>
)}
</div>
);
})()}
</div>
))}
)})}
{Object.keys((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).length === 0 && (
<div className="text-xs text-zinc-500">{t('configNoMCPServers')}</div>
)}

View File

@@ -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"
/>
<div className="mt-1 text-[11px] text-zinc-500">
<span className="font-mono text-zinc-400">skill_exec</span> is inherited automatically and does not need to be listed here.
</div>
{groups.length > 0 && (
<div className="mt-2 flex flex-wrap gap-2">
{groups.map((g) => (

View File

@@ -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',