mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-05-10 04:47:28 +08:00
Compare commits
2 Commits
v0.1.15
...
bb429e3fc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb429e3fc8 | ||
|
|
f043de5384 |
@@ -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` 段落。
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
37
pkg/agent/loop_skill_exec_test.go
Normal file
37
pkg/agent/loop_skill_exec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
557
pkg/tools/mcp.go
557
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,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
27
pkg/tools/skill_exec_test.go
Normal file
27
pkg/tools/skill_exec_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '沙箱',
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user