feat: add MCP tool and web UI management

This commit is contained in:
lpf
2026-03-07 22:09:52 +08:00
parent cff379809c
commit fb5a228e7d
16 changed files with 1836 additions and 4 deletions

View File

@@ -200,6 +200,16 @@ user -> main -> worker -> main -> user
完整示例见 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json)。
## MCP 服务支持
ClawGo 现在支持通过 `tools.mcp` 接入 `stdio` 型 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>`
示例配置可直接参考 [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) 中的 `tools.mcp` 段落。
## Prompt 文件约定
推荐把 agent prompt 独立为文件:

View File

@@ -200,6 +200,16 @@ Notes:
See [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json) for a full example.
## MCP Server Support
ClawGo now supports `stdio` 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
See the `tools.mcp` section in [config.example.json](/Users/lpf/Desktop/project/clawgo/config.example.json).
## Prompt File Convention
Keep agent prompts in dedicated files:

View File

@@ -166,6 +166,9 @@ func gatewayCmd() {
registryServer.SetSubagentHandler(func(cctx context.Context, action string, args map[string]interface{}) (interface{}, error) {
return agentLoop.HandleSubagentRuntime(cctx, action, args)
})
registryServer.SetToolsCatalogHandler(func() interface{} {
return agentLoop.GetToolCatalog()
})
registryServer.SetCronHandler(func(action string, args map[string]interface{}) (interface{}, error) {
getStr := func(k string) string {
v, _ := args[k].(string)

View File

@@ -253,6 +253,22 @@
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
}
},
"mcp": {
"enabled": false,
"request_timeout_sec": 20,
"servers": {
"context7": {
"enabled": false,
"transport": "stdio",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {},
"working_dir": "/absolute/path/to/project",
"description": "Example MCP server",
"package": "@upstash/context7-mcp"
}
}
}
},
"gateway": {

View File

@@ -17,6 +17,7 @@ import (
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"time"
@@ -165,6 +166,15 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
webFetchTool := tools.NewWebFetchTool(50000)
toolsRegistry.Register(webFetchTool)
toolsRegistry.Register(tools.NewParallelFetchTool(webFetchTool, maxParallelCalls, parallelSafe))
if cfg.Tools.MCP.Enabled {
mcpTool := tools.NewMCPTool(workspace, cfg.Tools.MCP)
toolsRegistry.Register(mcpTool)
discoveryCtx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Tools.MCP.RequestTimeoutSec)*time.Second)
for _, remoteTool := range mcpTool.DiscoverTools(discoveryCtx) {
toolsRegistry.Register(remoteTool)
}
cancel()
}
// Register message tool
messageTool := tools.NewMessageTool()
@@ -1680,6 +1690,22 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
return info
}
func (al *AgentLoop) GetToolCatalog() []map[string]interface{} {
if al == nil || al.tools == nil {
return nil
}
items := al.tools.Catalog()
sort.Slice(items, func(i, j int) bool {
return fmt.Sprint(items[i]["name"]) < fmt.Sprint(items[j]["name"])
})
for _, item := range items {
if fmt.Sprint(item["source"]) != "mcp" {
item["source"] = "local"
}
}
return items
}
// formatMessagesForLog formats messages for logging
func formatMessagesForLog(messages []providers.Message) string {
if len(messages) == 0 {

View File

@@ -46,6 +46,7 @@ type Server struct {
onConfigAfter func()
onCron func(action string, args map[string]interface{}) (interface{}, error)
onSubagents func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)
onToolsCatalog func() interface{}
webUIDir string
ekgCacheMu sync.Mutex
ekgCachePath string
@@ -81,9 +82,10 @@ func (s *Server) SetCronHandler(fn func(action string, args map[string]interface
func (s *Server) SetSubagentHandler(fn func(ctx context.Context, action string, args map[string]interface{}) (interface{}, error)) {
s.onSubagents = fn
}
func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
func (s *Server) SetGatewayVersion(v string) { s.gatewayVersion = strings.TrimSpace(v) }
func (s *Server) SetWebUIVersion(v string) { s.webuiVersion = strings.TrimSpace(v) }
func (s *Server) SetToolsCatalogHandler(fn func() interface{}) { s.onToolsCatalog = fn }
func (s *Server) SetWebUIDir(dir string) { s.webUIDir = strings.TrimSpace(dir) }
func (s *Server) SetGatewayVersion(v string) { s.gatewayVersion = strings.TrimSpace(v) }
func (s *Server) SetWebUIVersion(v string) { s.webuiVersion = strings.TrimSpace(v) }
func (s *Server) Start(ctx context.Context) error {
if s.mgr == nil {
@@ -112,6 +114,8 @@ func (s *Server) Start(ctx context.Context) error {
mux.HandleFunc("/webui/api/subagent_profiles", s.handleWebUISubagentProfiles)
mux.HandleFunc("/webui/api/subagents_runtime", s.handleWebUISubagentsRuntime)
mux.HandleFunc("/webui/api/tool_allowlist_groups", s.handleWebUIToolAllowlistGroups)
mux.HandleFunc("/webui/api/tools", s.handleWebUITools)
mux.HandleFunc("/webui/api/mcp/install", s.handleWebUIMCPInstall)
mux.HandleFunc("/webui/api/task_audit", s.handleWebUITaskAudit)
mux.HandleFunc("/webui/api/task_queue", s.handleWebUITaskQueue)
mux.HandleFunc("/webui/api/ekg_stats", s.handleWebUIEKGStats)
@@ -592,6 +596,73 @@ func (s *Server) handleWebUIVersion(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) handleWebUITools(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
toolsList := []map[string]interface{}{}
if s.onToolsCatalog != nil {
if items, ok := s.onToolsCatalog().([]map[string]interface{}); ok && items != nil {
toolsList = items
}
}
mcpItems := make([]map[string]interface{}, 0)
for _, item := range toolsList {
if strings.TrimSpace(fmt.Sprint(item["source"])) == "mcp" {
mcpItems = append(mcpItems, item)
}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"tools": toolsList,
"mcp_tools": mcpItems,
})
}
func (s *Server) handleWebUIMCPInstall(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var body struct {
Package string `json:"package"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
pkgName := strings.TrimSpace(body.Package)
if pkgName == "" {
http.Error(w, "package required", http.StatusBadRequest)
return
}
out, binName, binPath, err := ensureMCPPackageInstalled(r.Context(), pkgName)
if err != nil {
msg := err.Error()
if strings.TrimSpace(out) != "" {
msg = strings.TrimSpace(out) + "\n" + msg
}
http.Error(w, strings.TrimSpace(msg), http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"ok": true,
"package": pkgName,
"output": out,
"bin_name": binName,
"bin_path": binPath,
})
}
func (s *Server) handleWebUINodes(w http.ResponseWriter, r *http.Request) {
if !s.checkAuth(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
@@ -1694,6 +1765,102 @@ func ensureClawHubReady(ctx context.Context) (string, error) {
return strings.Join(outs, "\n"), fmt.Errorf("installed clawhub but executable still not found in PATH")
}
func ensureMCPPackageInstalled(ctx context.Context, pkgName string) (output string, binName string, binPath string, err error) {
pkgName = strings.TrimSpace(pkgName)
if pkgName == "" {
return "", "", "", fmt.Errorf("package empty")
}
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
}
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("resolved binary: %s", binPath))
return strings.Join(outs, "\n"), binName, binPath, nil
}
func resolveNpmPackageBin(ctx context.Context, pkgName string) (string, error) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
cmd := exec.CommandContext(cctx, "npm", "view", pkgName, "bin", "--json")
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("failed to query npm bin for %s: %w", pkgName, err)
}
trimmed := strings.TrimSpace(string(out))
if trimmed == "" || trimmed == "null" {
return "", fmt.Errorf("npm package %s does not expose a bin", pkgName)
}
var obj map[string]interface{}
if err := json.Unmarshal(out, &obj); err == nil && len(obj) > 0 {
keys := make([]string, 0, len(obj))
for key := range obj {
keys = append(keys, key)
}
sort.Strings(keys)
return keys[0], nil
}
var text string
if err := json.Unmarshal(out, &text); err == nil && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text), nil
}
return "", fmt.Errorf("unable to resolve bin for npm package %s", pkgName)
}
func resolveInstalledBinary(ctx context.Context, binName string) string {
binName = strings.TrimSpace(binName)
if binName == "" {
return ""
}
if p, err := exec.LookPath(binName); err == nil {
return p
}
prefix := strings.TrimSpace(npmGlobalPrefix(ctx))
if prefix != "" {
cand := filepath.Join(prefix, "bin", binName)
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
return cand
}
}
cands := []string{
filepath.Join("/usr/local/bin", binName),
filepath.Join("/opt/homebrew/bin", binName),
filepath.Join(os.Getenv("HOME"), ".npm-global", "bin", binName),
}
for _, cand := range cands {
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
return cand
}
}
return ""
}
func shellEscapeArg(in string) string {
if strings.TrimSpace(in) == "" {
return "''"
}
return "'" + strings.ReplaceAll(in, "'", `'\''`) + "'"
}
func importSkillArchiveFromMultipart(r *http.Request, skillsDir string) ([]string, error) {
if err := r.ParseMultipartForm(128 << 20); err != nil {
return nil, err

View File

@@ -326,10 +326,28 @@ type SandboxConfig struct {
type FilesystemConfig struct{}
type MCPServerConfig struct {
Enabled bool `json:"enabled"`
Transport string `json:"transport"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
Description string `json:"description,omitempty"`
Package string `json:"package,omitempty"`
}
type MCPToolsConfig struct {
Enabled bool `json:"enabled"`
RequestTimeoutSec int `json:"request_timeout_sec"`
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Shell ShellConfig `json:"shell"`
Filesystem FilesystemConfig `json:"filesystem"`
MCP MCPToolsConfig `json:"mcp"`
}
type LoggingConfig struct {
@@ -540,6 +558,11 @@ func DefaultConfig() *Config {
},
},
Filesystem: FilesystemConfig{},
MCP: MCPToolsConfig{
Enabled: false,
RequestTimeoutSec: 20,
Servers: map[string]MCPServerConfig{},
},
},
Logging: LoggingConfig{
Enabled: true,

View File

@@ -165,6 +165,7 @@ func Validate(cfg *Config) []error {
if cfg.Memory.RecentDays <= 0 {
errs = append(errs, fmt.Errorf("memory.recent_days must be > 0"))
}
errs = append(errs, validateMCPTools(cfg)...)
if cfg.Channels.InboundMessageIDDedupeTTLSeconds <= 0 {
errs = append(errs, fmt.Errorf("channels.inbound_message_id_dedupe_ttl_seconds must be > 0"))
@@ -212,6 +213,40 @@ func Validate(cfg *Config) []error {
return errs
}
func validateMCPTools(cfg *Config) []error {
var errs []error
mcp := cfg.Tools.MCP
if !mcp.Enabled {
return errs
}
if mcp.RequestTimeoutSec <= 0 {
errs = append(errs, fmt.Errorf("tools.mcp.request_timeout_sec must be > 0 when tools.mcp.enabled=true"))
}
for name, server := range mcp.Servers {
if strings.TrimSpace(name) == "" {
errs = append(errs, fmt.Errorf("tools.mcp.servers contains an empty server name"))
continue
}
if !server.Enabled {
continue
}
transport := strings.ToLower(strings.TrimSpace(server.Transport))
if transport == "" {
transport = "stdio"
}
if transport != "stdio" {
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.transport must be 'stdio'", name))
}
if strings.TrimSpace(server.Command) == "" {
errs = append(errs, fmt.Errorf("tools.mcp.servers.%s.command is required when enabled=true", 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))
}
}
return errs
}
func validateAgentRouter(cfg *Config) []error {
router := cfg.Agents.Router
var errs []error

View File

@@ -23,6 +23,10 @@ type ResourceScopedTool interface {
ResourceKeys(args map[string]interface{}) []string
}
type CatalogTool interface {
CatalogEntry() map[string]interface{}
}
func ToolToSchema(tool Tool) map[string]interface{} {
return map[string]interface{}{
"type": "function",

800
pkg/tools/mcp.go Normal file
View File

@@ -0,0 +1,800 @@
package tools
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"hash/fnv"
"io"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"clawgo/pkg/config"
)
const mcpProtocolVersion = "2025-06-18"
type MCPTool struct {
workspace string
cfg config.MCPToolsConfig
}
type MCPRemoteTool struct {
bridge *MCPTool
serverName string
remoteName string
localName string
description string
parameters map[string]interface{}
}
func NewMCPTool(workspace string, cfg config.MCPToolsConfig) *MCPTool {
if cfg.RequestTimeoutSec <= 0 {
cfg.RequestTimeoutSec = 20
}
if cfg.Servers == nil {
cfg.Servers = map[string]config.MCPServerConfig{}
}
return &MCPTool{workspace: workspace, cfg: cfg}
}
func (t *MCPTool) Name() string {
return "mcp"
}
func (t *MCPTool) Description() string {
return "Call configured MCP servers over stdio. Supports listing servers, tools, resources, prompts, and invoking remote MCP tools."
}
func (t *MCPTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"description": "Operation to perform",
"enum": []string{"list_servers", "list_tools", "call_tool", "list_resources", "read_resource", "list_prompts", "get_prompt"},
},
"server": map[string]interface{}{
"type": "string",
"description": "Configured MCP server name",
},
"tool": map[string]interface{}{
"type": "string",
"description": "MCP tool name for action=call_tool",
},
"arguments": map[string]interface{}{
"type": "object",
"description": "Arguments for call_tool or get_prompt",
},
"uri": map[string]interface{}{
"type": "string",
"description": "Resource URI for action=read_resource",
},
"prompt": map[string]interface{}{
"type": "string",
"description": "Prompt name for action=get_prompt",
},
},
"required": []string{"action"},
}
}
func (t *MCPTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
action := strings.TrimSpace(mcpStringArg(args, "action"))
if action == "" {
return "", fmt.Errorf("action is required")
}
if action == "list_servers" {
return t.listServers(), nil
}
serverName := strings.TrimSpace(mcpStringArg(args, "server"))
if serverName == "" {
return "", fmt.Errorf("server is required for action %q", action)
}
serverCfg, ok := t.cfg.Servers[serverName]
if !ok || !serverCfg.Enabled {
return "", fmt.Errorf("mcp server %q is not configured or not enabled", serverName)
}
timeout := time.Duration(t.cfg.RequestTimeoutSec) * time.Second
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > 0 && remaining < timeout {
timeout = remaining
}
}
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client, err := newMCPStdioClient(callCtx, t.workspace, serverName, serverCfg)
if err != nil {
return "", err
}
defer client.Close()
switch action {
case "list_tools":
out, err := client.listAll(callCtx, "tools/list", "tools")
if err != nil {
return "", err
}
return prettyJSON(out)
case "call_tool":
toolName := strings.TrimSpace(mcpStringArg(args, "tool"))
if toolName == "" {
return "", fmt.Errorf("tool is required for action=call_tool")
}
params := map[string]interface{}{
"name": toolName,
"arguments": mcpObjectArg(args, "arguments"),
}
out, err := client.request(callCtx, "tools/call", params)
if err != nil {
return "", err
}
return prettyJSON(out)
case "list_resources":
out, err := client.listAll(callCtx, "resources/list", "resources")
if err != nil {
return "", err
}
return prettyJSON(out)
case "read_resource":
resourceURI := strings.TrimSpace(mcpStringArg(args, "uri"))
if resourceURI == "" {
return "", fmt.Errorf("uri is required for action=read_resource")
}
out, err := client.request(callCtx, "resources/read", map[string]interface{}{"uri": resourceURI})
if err != nil {
return "", err
}
return prettyJSON(out)
case "list_prompts":
out, err := client.listAll(callCtx, "prompts/list", "prompts")
if err != nil {
return "", err
}
return prettyJSON(out)
case "get_prompt":
promptName := strings.TrimSpace(mcpStringArg(args, "prompt"))
if promptName == "" {
return "", fmt.Errorf("prompt is required for action=get_prompt")
}
out, err := client.request(callCtx, "prompts/get", map[string]interface{}{
"name": promptName,
"arguments": mcpObjectArg(args, "arguments"),
})
if err != nil {
return "", err
}
return prettyJSON(out)
default:
return "", fmt.Errorf("unsupported action %q", action)
}
}
func (t *MCPTool) DiscoverTools(ctx context.Context) []Tool {
if t == nil || !t.cfg.Enabled {
return nil
}
names := make([]string, 0, len(t.cfg.Servers))
for name, server := range t.cfg.Servers {
if server.Enabled {
names = append(names, name)
}
}
sort.Strings(names)
tools := make([]Tool, 0)
seen := map[string]int{}
for _, serverName := range names {
serverCfg := t.cfg.Servers[serverName]
client, err := newMCPStdioClient(ctx, t.workspace, serverName, serverCfg)
if err != nil {
continue
}
result, err := client.listAll(ctx, "tools/list", "tools")
_ = client.Close()
if err != nil {
continue
}
items, _ := result["tools"].([]interface{})
for _, item := range items {
toolMap, _ := item.(map[string]interface{})
remoteName := strings.TrimSpace(mcpStringArg(toolMap, "name"))
if remoteName == "" {
continue
}
localName := buildMCPDynamicToolName(serverName, remoteName)
if count := seen[localName]; count > 0 {
localName = fmt.Sprintf("%s_%d", localName, count+1)
}
seen[localName]++
tools = append(tools, &MCPRemoteTool{
bridge: t,
serverName: serverName,
remoteName: remoteName,
localName: localName,
description: buildMCPDynamicToolDescription(serverName, toolMap),
parameters: normalizeMCPSchema(toolMap["inputSchema"]),
})
}
}
return tools
}
func (t *MCPTool) callServerTool(ctx context.Context, serverName, remoteToolName string, arguments map[string]interface{}) (string, error) {
serverCfg, ok := t.cfg.Servers[serverName]
if !ok || !serverCfg.Enabled {
return "", fmt.Errorf("mcp server %q is not configured or not enabled", serverName)
}
timeout := time.Duration(t.cfg.RequestTimeoutSec) * time.Second
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > 0 && remaining < timeout {
timeout = remaining
}
}
callCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
client, err := newMCPStdioClient(callCtx, t.workspace, serverName, serverCfg)
if err != nil {
return "", err
}
defer client.Close()
out, err := client.request(callCtx, "tools/call", map[string]interface{}{
"name": remoteToolName,
"arguments": arguments,
})
if err != nil {
return "", err
}
return renderMCPToolCallResult(out)
}
func (t *MCPTool) listServers() string {
type item struct {
Name string `json:"name"`
Transport string `json:"transport"`
Command string `json:"command"`
WorkingDir string `json:"working_dir,omitempty"`
Description string `json:"description,omitempty"`
}
names := make([]string, 0, len(t.cfg.Servers))
for name, server := range t.cfg.Servers {
if server.Enabled {
names = append(names, name)
}
}
sort.Strings(names)
items := make([]item, 0, len(names))
for _, name := range names {
server := t.cfg.Servers[name]
transport := strings.TrimSpace(server.Transport)
if transport == "" {
transport = "stdio"
}
items = append(items, item{
Name: name,
Transport: transport,
Command: server.Command,
WorkingDir: server.WorkingDir,
Description: server.Description,
})
}
out, _ := json.MarshalIndent(map[string]interface{}{"servers": items}, "", " ")
return string(out)
}
func (t *MCPRemoteTool) Name() string {
return t.localName
}
func (t *MCPRemoteTool) Description() string {
return t.description
}
func (t *MCPRemoteTool) Parameters() map[string]interface{} {
return t.parameters
}
func (t *MCPRemoteTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
return t.bridge.callServerTool(ctx, t.serverName, t.remoteName, args)
}
func (t *MCPRemoteTool) CatalogEntry() map[string]interface{} {
return map[string]interface{}{
"source": "mcp",
"mcp": map[string]interface{}{
"server": t.serverName,
"remote_tool": t.remoteName,
},
}
}
type mcpClient struct {
workspace string
serverName string
cmd *exec.Cmd
stdin io.WriteCloser
reader *bufio.Reader
stderr bytes.Buffer
writeMu sync.Mutex
waiters sync.Map
nextID atomic.Int64
}
type mcpInbound struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params map[string]interface{} `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *mcpResponseError `json:"error,omitempty"`
}
type mcpResponseError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type mcpResponseWaiter struct {
ch chan mcpInbound
}
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)
}
cmd := exec.CommandContext(ctx, command, cfg.Args...)
cmd.Env = buildMCPEnv(cfg.Env)
cmd.Dir = resolveMCPWorkingDir(workspace, cfg.WorkingDir)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("open stdin for mcp server %q: %w", serverName, err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("open stdout for mcp server %q: %w", serverName, err)
}
client := &mcpClient{
workspace: workspace,
serverName: serverName,
cmd: cmd,
stdin: stdin,
reader: bufio.NewReader(stdout),
}
cmd.Stderr = &client.stderr
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start mcp server %q: %w", serverName, err)
}
go client.readLoop()
if err := client.initialize(ctx); err != nil {
client.Close()
return nil, err
}
return client, nil
}
func (c *mcpClient) Close() error {
if c == nil || c.cmd == nil {
return nil
}
_ = c.stdin.Close()
done := make(chan error, 1)
go func() {
done <- c.cmd.Wait()
}()
select {
case err := <-done:
return err
case <-time.After(500 * time.Millisecond):
if c.cmd.Process != nil {
_ = c.cmd.Process.Kill()
}
<-done
return nil
}
}
func (c *mcpClient) 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 *mcpClient) 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 *mcpClient) 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)
msg := map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}
if err := c.writeMessage(msg); 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 *mcpClient) notify(method string, params map[string]interface{}) error {
return c.writeMessage(map[string]interface{}{
"jsonrpc": "2.0",
"method": method,
"params": params,
})
}
func (c *mcpClient) writeMessage(payload map[string]interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
frame := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(data), data)
c.writeMu.Lock()
defer c.writeMu.Unlock()
_, err = io.WriteString(c.stdin, frame)
return err
}
func (c *mcpClient) readLoop() {
for {
msg, err := c.readMessage()
if err != nil {
c.failAll(err)
return
}
if msg.Method != "" && msg.ID != nil {
_ = c.handleServerRequest(msg)
continue
}
if msg.Method != "" {
continue
}
if key, ok := normalizeMCPID(msg.ID); ok {
if raw, ok := c.waiters.Load(key); ok {
raw.(*mcpResponseWaiter).ch <- msg
}
}
}
}
func (c *mcpClient) handleServerRequest(msg mcpInbound) error {
method := strings.TrimSpace(msg.Method)
switch method {
case "roots/list":
return c.reply(msg.ID, map[string]interface{}{
"roots": []map[string]interface{}{
{
"uri": fileURI(resolveMCPWorkingDir(c.workspace, "")),
"name": filepath.Base(resolveMCPWorkingDir(c.workspace, "")),
},
},
})
case "ping":
return c.reply(msg.ID, map[string]interface{}{})
default:
return c.replyError(msg.ID, -32601, "method not supported by clawgo mcp client")
}
}
func (c *mcpClient) reply(id interface{}, result map[string]interface{}) error {
return c.writeMessage(map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": result,
})
}
func (c *mcpClient) replyError(id interface{}, code int, message string) error {
return c.writeMessage(map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"error": map[string]interface{}{
"code": code,
"message": message,
},
})
}
func (c *mcpClient) failAll(err error) {
message := err.Error()
if stderr := strings.TrimSpace(c.stderr.String()); stderr != "" {
message += ": " + stderr
}
c.waiters.Range(func(_, value interface{}) bool {
value.(*mcpResponseWaiter).ch <- mcpInbound{
Error: &mcpResponseError{Message: message},
}
return true
})
}
func (c *mcpClient) readMessage() (mcpInbound, error) {
length := 0
for {
line, err := c.reader.ReadString('\n')
if err != nil {
return mcpInbound{}, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
if strings.EqualFold(strings.TrimSpace(parts[0]), "Content-Length") {
length, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
}
}
if length <= 0 {
return mcpInbound{}, fmt.Errorf("invalid mcp content length")
}
body := make([]byte, length)
if _, err := io.ReadFull(c.reader, body); err != nil {
return mcpInbound{}, err
}
var msg mcpInbound
if err := json.Unmarshal(body, &msg); err != nil {
return mcpInbound{}, err
}
return msg, nil
}
func buildMCPEnv(overrides map[string]string) []string {
env := os.Environ()
path := os.Getenv("PATH")
fallback := "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/homebrew/bin:/opt/homebrew/sbin"
if strings.TrimSpace(path) == "" {
env = append(env, "PATH="+fallback)
} else {
env = append(env, "PATH="+path+":"+fallback)
}
for key, value := range overrides {
env = append(env, key+"="+value)
}
return env
}
func resolveMCPWorkingDir(workspace, wd string) string {
wd = strings.TrimSpace(wd)
if wd != "" {
return wd
}
if abs, err := filepath.Abs(workspace); err == nil {
return abs
}
return workspace
}
func fileURI(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
abs = path
}
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}).String()
}
func normalizeMCPID(id interface{}) (string, bool) {
switch v := id.(type) {
case string:
return v, v != ""
case float64:
return strconv.FormatInt(int64(v), 10), true
case int:
return strconv.Itoa(v), true
case int64:
return strconv.FormatInt(v, 10), true
default:
return "", false
}
}
func prettyJSON(v interface{}) (string, error) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return "", err
}
return string(data), nil
}
func buildMCPDynamicToolName(serverName, remoteName string) string {
base := "mcp__" + sanitizeMCPToolSegment(serverName) + "__" + sanitizeMCPToolSegment(remoteName)
if len(base) <= 64 {
return base
}
hash := fnv.New32a()
_, _ = hash.Write([]byte(serverName + "::" + remoteName))
suffix := fmt.Sprintf("_%x", hash.Sum32())
trimmed := base
if len(trimmed)+len(suffix) > 64 {
trimmed = trimmed[:64-len(suffix)]
}
return trimmed + suffix
}
func ParseMCPDynamicToolName(name string) (serverName string, remoteName string, ok bool) {
const prefix = "mcp__"
if !strings.HasPrefix(strings.TrimSpace(name), prefix) {
return "", "", false
}
rest := strings.TrimPrefix(strings.TrimSpace(name), prefix)
parts := strings.SplitN(rest, "__", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return "", "", false
}
return parts[0], parts[1], true
}
var mcpToolSegmentPattern = regexp.MustCompile(`[^a-zA-Z0-9_]+`)
func sanitizeMCPToolSegment(in string) string {
in = strings.TrimSpace(strings.ToLower(in))
in = mcpToolSegmentPattern.ReplaceAllString(in, "_")
in = strings.Trim(in, "_")
if in == "" {
return "tool"
}
return in
}
func buildMCPDynamicToolDescription(serverName string, toolMap map[string]interface{}) string {
desc := strings.TrimSpace(mcpStringArg(toolMap, "description"))
remoteName := strings.TrimSpace(mcpStringArg(toolMap, "name"))
if desc == "" {
desc = fmt.Sprintf("Proxy to MCP tool %q on server %q.", remoteName, serverName)
} else {
desc = fmt.Sprintf("%s (MCP server: %s, remote tool: %s)", desc, serverName, remoteName)
}
return desc
}
func normalizeMCPSchema(raw interface{}) map[string]interface{} {
schema, _ := raw.(map[string]interface{})
if schema == nil {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},
}
}
out := map[string]interface{}{}
for k, v := range schema {
out[k] = v
}
if _, ok := out["type"]; !ok {
out["type"] = "object"
}
if _, ok := out["properties"]; !ok {
out["properties"] = map[string]interface{}{}
}
return out
}
func renderMCPToolCallResult(result map[string]interface{}) (string, error) {
if result == nil {
return "", nil
}
if content, ok := result["content"].([]interface{}); ok && len(content) > 0 {
parts := make([]string, 0, len(content))
for _, item := range content {
m, _ := item.(map[string]interface{})
if m == nil {
continue
}
kind := strings.TrimSpace(mcpStringArg(m, "type"))
switch kind {
case "text":
if text := mcpStringArg(m, "text"); strings.TrimSpace(text) != "" {
parts = append(parts, text)
}
default:
if text := mcpStringArg(m, "text"); strings.TrimSpace(text) != "" {
parts = append(parts, text)
} else {
data, err := prettyJSON(m)
if err == nil {
parts = append(parts, data)
}
}
}
}
if len(parts) > 0 {
if structured, ok := result["structuredContent"]; ok {
data, err := prettyJSON(structured)
if err == nil && strings.TrimSpace(data) != "" && data != "{}" {
parts = append(parts, data)
}
}
return strings.Join(parts, "\n\n"), nil
}
}
return prettyJSON(result)
}
func mcpStringArg(args map[string]interface{}, key string) string {
v, _ := args[key].(string)
return v
}
func mcpObjectArg(args map[string]interface{}, key string) map[string]interface{} {
v, _ := args[key].(map[string]interface{})
if v == nil {
return map[string]interface{}{}
}
return v
}

354
pkg/tools/mcp_test.go Normal file
View File

@@ -0,0 +1,354 @@
package tools
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"testing"
"time"
"clawgo/pkg/config"
)
func TestMCPToolListServers(t *testing.T) {
tool := NewMCPTool("/tmp/workspace", config.MCPToolsConfig{
Enabled: true,
RequestTimeoutSec: 5,
Servers: map[string]config.MCPServerConfig{
"demo": {
Enabled: true,
Transport: "stdio",
Command: "demo-server",
Description: "demo",
},
"disabled": {
Enabled: false,
Transport: "stdio",
Command: "nope",
},
},
})
out, err := tool.Execute(context.Background(), map[string]interface{}{"action": "list_servers"})
if err != nil {
t.Fatalf("list_servers returned error: %v", err)
}
if !strings.Contains(out, `"name": "demo"`) {
t.Fatalf("expected enabled server in output, got: %s", out)
}
if strings.Contains(out, "disabled") {
t.Fatalf("did not expect disabled server in output, got: %s", out)
}
}
func TestMCPToolCallTool(t *testing.T) {
tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{
Enabled: true,
RequestTimeoutSec: 5,
Servers: map[string]config.MCPServerConfig{
"helper": {
Enabled: true,
Transport: "stdio",
Command: os.Args[0],
Args: []string{"-test.run=TestMCPHelperProcess", "--"},
Env: map[string]string{
"GO_WANT_HELPER_PROCESS": "1",
},
},
},
})
out, err := tool.Execute(context.Background(), map[string]interface{}{
"action": "call_tool",
"server": "helper",
"tool": "echo",
"arguments": map[string]interface{}{"text": "hello"},
})
if err != nil {
t.Fatalf("call_tool returned error: %v", err)
}
if !strings.Contains(out, "echo:hello") {
t.Fatalf("expected echo output, got: %s", out)
}
}
func TestMCPToolDiscoverTools(t *testing.T) {
tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{
Enabled: true,
RequestTimeoutSec: 5,
Servers: map[string]config.MCPServerConfig{
"helper": {
Enabled: true,
Transport: "stdio",
Command: os.Args[0],
Args: []string{"-test.run=TestMCPHelperProcess", "--"},
Env: map[string]string{
"GO_WANT_HELPER_PROCESS": "1",
},
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
discovered := tool.DiscoverTools(ctx)
if len(discovered) != 1 {
t.Fatalf("expected 1 discovered tool, got %d", len(discovered))
}
if got := discovered[0].Name(); got != "mcp__helper__echo" {
t.Fatalf("unexpected discovered tool name: %s", got)
}
out, err := discovered[0].Execute(ctx, map[string]interface{}{"text": "world"})
if err != nil {
t.Fatalf("discovered tool execute returned error: %v", err)
}
if strings.TrimSpace(out) != "echo:world" {
t.Fatalf("unexpected discovered tool output: %q", out)
}
}
func TestMCPHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
runMCPHelper()
os.Exit(0)
}
func runMCPHelper() {
reader := bufio.NewReader(os.Stdin)
writer := bufio.NewWriter(os.Stdout)
for {
msg, err := readHelperFrame(reader)
if err != nil {
return
}
method, _ := msg["method"].(string)
id, hasID := msg["id"]
switch method {
case "initialize":
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": map[string]interface{}{
"protocolVersion": mcpProtocolVersion,
"capabilities": map[string]interface{}{
"tools": map[string]interface{}{},
"resources": map[string]interface{}{},
"prompts": map[string]interface{}{},
},
"serverInfo": map[string]interface{}{
"name": "helper",
"version": "1.0.0",
},
},
})
case "notifications/initialized":
continue
case "tools/list":
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": map[string]interface{}{
"tools": []map[string]interface{}{
{
"name": "echo",
"description": "Echo the provided text",
"inputSchema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"text": map[string]interface{}{
"type": "string",
},
},
"required": []string{"text"},
},
},
},
},
})
case "tools/call":
params, _ := msg["params"].(map[string]interface{})
args, _ := params["arguments"].(map[string]interface{})
text, _ := args["text"].(string)
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": map[string]interface{}{
"content": []map[string]interface{}{
{
"type": "text",
"text": "echo:" + text,
},
},
},
})
case "resources/list":
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": map[string]interface{}{
"resources": []map[string]interface{}{
{"uri": "file:///tmp/demo.txt", "name": "demo"},
},
},
})
case "resources/read":
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": map[string]interface{}{
"contents": []map[string]interface{}{
{"uri": "file:///tmp/demo.txt", "mimeType": "text/plain", "text": "demo content"},
},
},
})
case "prompts/list":
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": map[string]interface{}{
"prompts": []map[string]interface{}{
{"name": "greeter", "description": "Greets"},
},
},
})
case "prompts/get":
params, _ := msg["params"].(map[string]interface{})
args, _ := params["arguments"].(map[string]interface{})
name, _ := args["name"].(string)
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"result": map[string]interface{}{
"description": "Greets",
"messages": []map[string]interface{}{
{
"role": "user",
"content": map[string]interface{}{
"type": "text",
"text": "hello " + name,
},
},
},
},
})
default:
if hasID {
writeHelperFrame(writer, map[string]interface{}{
"jsonrpc": "2.0",
"id": id,
"error": map[string]interface{}{
"code": -32601,
"message": "method not found",
},
})
}
}
}
}
func readHelperFrame(r *bufio.Reader) (map[string]interface{}, error) {
length := 0
for {
line, err := r.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
break
}
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "Content-Length") {
length, _ = strconv.Atoi(strings.TrimSpace(parts[1]))
}
}
body := make([]byte, length)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
var msg map[string]interface{}
if err := json.Unmarshal(body, &msg); err != nil {
return nil, err
}
return msg, nil
}
func writeHelperFrame(w *bufio.Writer, payload map[string]interface{}) {
data, _ := json.Marshal(payload)
_, _ = fmt.Fprintf(w, "Content-Length: %d\r\n\r\n%s", len(data), data)
_ = w.Flush()
}
func TestValidateMCPTools(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.MCP.Enabled = true
cfg.Tools.MCP.RequestTimeoutSec = 0
cfg.Tools.MCP.Servers = map[string]config.MCPServerConfig{
"bad": {
Enabled: true,
Transport: "http",
Command: "",
WorkingDir: "relative",
},
}
errs := config.Validate(cfg)
if len(errs) == 0 {
t.Fatal("expected validation errors")
}
got := make([]string, 0, len(errs))
for _, err := range errs {
got = append(got, err.Error())
}
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",
} {
if !strings.Contains(joined, want) {
t.Fatalf("expected validation error %q in:\n%s", want, joined)
}
}
}
func TestMCPToolListTools(t *testing.T) {
tool := NewMCPTool(t.TempDir(), config.MCPToolsConfig{
Enabled: true,
RequestTimeoutSec: 5,
Servers: map[string]config.MCPServerConfig{
"helper": {
Enabled: true,
Transport: "stdio",
Command: os.Args[0],
Args: []string{"-test.run=TestMCPHelperProcess", "--"},
Env: map[string]string{
"GO_WANT_HELPER_PROCESS": "1",
},
},
},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := tool.Execute(ctx, map[string]interface{}{
"action": "list_tools",
"server": "helper",
})
if err != nil {
t.Fatalf("list_tools returned error: %v", err)
}
if !strings.Contains(out, `"name": "echo"`) {
t.Fatalf("expected 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)
}
}

View File

@@ -87,6 +87,25 @@ func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
return definitions
}
func (r *ToolRegistry) Catalog() []map[string]interface{} {
cur, _ := r.snapshot.Load().(map[string]Tool)
items := make([]map[string]interface{}, 0, len(cur))
for _, tool := range cur {
item := map[string]interface{}{
"name": tool.Name(),
"description": tool.Description(),
"parameters": tool.Parameters(),
}
if ct, ok := tool.(CatalogTool); ok {
for k, v := range ct.CatalogEntry() {
item[k] = v
}
}
items = append(items, item)
}
return items
}
// List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string {
cur, _ := r.snapshot.Load().(map[string]Tool)

View File

@@ -9,6 +9,7 @@ import Config from './pages/Config';
import Cron from './pages/Cron';
import Logs from './pages/Logs';
import Skills from './pages/Skills';
import MCP from './pages/MCP';
import Memory from './pages/Memory';
import TaskAudit from './pages/TaskAudit';
import EKG from './pages/EKG';
@@ -28,6 +29,7 @@ export default function App() {
<Route path="logs" element={<Logs />} />
<Route path="log-codes" element={<LogCodes />} />
<Route path="skills" element={<Skills />} />
<Route path="mcp" element={<MCP />} />
<Route path="config" element={<Config />} />
<Route path="cron" element={<Cron />} />
<Route path="memory" element={<Memory />} />

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Boxes, PanelLeftClose, PanelLeftOpen } from 'lucide-react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Terminal, Zap, FolderOpen, ClipboardList, BrainCircuit, Hash, Bot, Boxes, PanelLeftClose, PanelLeftOpen, Plug } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import NavItem from './NavItem';
@@ -29,6 +29,7 @@ const Sidebar: React.FC = () => {
title: t('sidebarConfig'),
items: [
{ icon: <Settings className="w-5 h-5" />, label: t('config'), to: '/config' },
{ icon: <Plug className="w-5 h-5" />, label: t('mcpServices'), to: '/mcp' },
{ icon: <Bot className="w-5 h-5" />, label: t('subagentProfiles'), to: '/subagent-profiles' },
{ icon: <Clock className="w-5 h-5" />, label: t('cronJobs'), to: '/cron' },
],

View File

@@ -8,6 +8,8 @@ const resources = {
dashboard: 'Dashboard',
chat: 'Chat',
config: 'Config',
mcpServices: 'MCP',
mcpServicesHint: 'Manage MCP servers, install packages, and inspect discovered remote tools.',
cronJobs: 'Cron Jobs',
nodes: 'Nodes',
agentTree: 'Agent Tree',
@@ -298,6 +300,23 @@ const resources = {
configProxies: 'Proxies',
configNewProviderName: 'new provider name',
configNoCustomProviders: 'No custom providers yet.',
configMCPServers: 'MCP Servers',
configNewMCPServerName: 'new MCP server name',
configNoMCPServers: 'No MCP servers configured yet.',
configMCPInstallTitle: 'Install MCP Server Package',
configMCPInstallMessage: 'Install an npm package for MCP server "{{name}}"?',
configMCPInstallPlaceholder: '@scope/package',
configMCPInstalling: 'Installing MCP package...',
configMCPInstallFailedTitle: 'MCP install failed',
configMCPInstallFailedMessage: 'Failed to install MCP package',
configMCPInstallDoneTitle: 'MCP package installed',
configMCPInstallDoneMessage: 'Installed {{package}} and resolved binary {{bin}}.',
configMCPInstallDoneFallback: 'MCP package installed.',
configMCPDiscoveredTools: 'Discovered MCP Tools',
configMCPDiscoveredToolsCount: '{{count}} discovered',
configNoMCPDiscoveredTools: 'No MCP tools discovered yet.',
configDeleteMCPServerConfirmTitle: 'Delete MCP Server',
configDeleteMCPServerConfirmMessage: 'Delete MCP server "{{name}}" from current config?',
configNoGroups: 'No config groups found.',
configDiffPreviewCount: 'Diff Preview ({{count}} items)',
saveConfigFailed: 'Failed to save config',
@@ -388,6 +407,7 @@ const resources = {
api_base: 'API Base',
protocol: 'Protocol',
models: 'Models',
command: 'Command',
responses: 'Responses',
streaming: 'Streaming',
web_search_enabled: 'Web Search Enabled',
@@ -403,6 +423,7 @@ const resources = {
version: 'Version',
name: 'Name',
description: 'Description',
package: 'Package',
system_prompt: 'System Prompt',
tools: 'Tools',
auth: 'Authentication',
@@ -479,8 +500,14 @@ const resources = {
sandbox: 'Sandbox',
image: 'Image',
web: 'Web',
mcp: 'MCP',
search: 'Search',
max_results: 'Max Results',
request_timeout_sec: 'Request Timeout (Seconds)',
servers: 'Servers',
transport: 'Transport',
args: 'Arguments',
env: 'Environment',
proxies: 'Proxies',
cross_session_call_id: 'Cross-session Call ID',
supports_responses_compact: 'Supports Responses Compact',
@@ -502,6 +529,8 @@ const resources = {
dashboard: '仪表盘',
chat: '对话',
config: '配置',
mcpServices: 'MCP',
mcpServicesHint: '管理 MCP 服务、安装服务包,并查看已发现的远端工具。',
cronJobs: '定时任务',
nodes: '节点',
agentTree: '代理树',
@@ -792,6 +821,23 @@ const resources = {
configProxies: '代理配置',
configNewProviderName: '新 provider 名称',
configNoCustomProviders: '暂无自定义 provider。',
configMCPServers: 'MCP 服务',
configNewMCPServerName: '新的 MCP 服务名',
configNoMCPServers: '暂无 MCP 服务配置。',
configMCPInstallTitle: '安装 MCP 服务包',
configMCPInstallMessage: '是否为 MCP 服务 “{{name}}” 安装 npm 包?',
configMCPInstallPlaceholder: '@scope/package',
configMCPInstalling: '正在安装 MCP 包...',
configMCPInstallFailedTitle: 'MCP 安装失败',
configMCPInstallFailedMessage: '安装 MCP 包失败',
configMCPInstallDoneTitle: 'MCP 包安装完成',
configMCPInstallDoneMessage: '已安装 {{package}},并解析到可执行文件 {{bin}}。',
configMCPInstallDoneFallback: 'MCP 包已安装。',
configMCPDiscoveredTools: '已发现的 MCP 工具',
configMCPDiscoveredToolsCount: '已发现 {{count}} 个',
configNoMCPDiscoveredTools: '暂未发现 MCP 工具。',
configDeleteMCPServerConfirmTitle: '删除 MCP 服务',
configDeleteMCPServerConfirmMessage: '确认从当前配置中删除 MCP 服务 “{{name}}”吗?',
configNoGroups: '未找到配置分组。',
configDiffPreviewCount: '配置差异预览({{count}}项)',
saveConfigFailed: '保存配置失败',
@@ -882,6 +928,7 @@ const resources = {
api_base: 'API 基础地址',
protocol: '协议',
models: '模型列表',
command: '命令',
responses: 'Responses 配置',
streaming: '流式输出',
web_search_enabled: '启用网页搜索',
@@ -897,6 +944,7 @@ const resources = {
version: '版本',
name: '名称',
description: '描述',
package: '包名',
system_prompt: '系统提示词',
tools: '工具',
auth: '身份验证',
@@ -973,8 +1021,14 @@ const resources = {
sandbox: '沙箱',
image: '镜像',
web: 'Web',
mcp: 'MCP',
search: '搜索',
max_results: '最大结果数',
request_timeout_sec: '请求超时(秒)',
servers: '服务列表',
transport: '传输方式',
args: '参数',
env: '环境变量',
proxies: '代理集合',
cross_session_call_id: '跨会话调用 ID',
supports_responses_compact: '支持紧凑 responses',

308
webui/src/pages/MCP.tsx Normal file
View File

@@ -0,0 +1,308 @@
import React, { useEffect, useMemo, useState } from 'react';
import { RefreshCw, Save } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
function setPath(obj: any, path: string, value: any) {
const keys = path.split('.');
const next = JSON.parse(JSON.stringify(obj || {}));
let cur = next;
for (let i = 0; i < keys.length - 1; i++) {
const k = keys[i];
if (typeof cur[k] !== 'object' || cur[k] === null) cur[k] = {};
cur = cur[k];
}
cur[keys[keys.length - 1]] = value;
return next;
}
const MCP: React.FC = () => {
const { t } = useTranslation();
const { cfg, setCfg, q, loadConfig, setConfigEditing } = useAppContext();
const ui = useUI();
const [newMCPServerName, setNewMCPServerName] = useState('');
const [mcpTools, setMcpTools] = useState<Array<{ name: string; description?: string; mcp?: { server?: string; remote_tool?: string } }>>([]);
const [baseline, setBaseline] = useState<any>(null);
const currentPayload = useMemo(() => cfg || {}, [cfg]);
const isDirty = useMemo(() => {
if (baseline == null) return false;
return JSON.stringify(baseline) !== JSON.stringify(currentPayload);
}, [baseline, currentPayload]);
useEffect(() => {
if (baseline == null && cfg && Object.keys(cfg).length > 0) {
setBaseline(JSON.parse(JSON.stringify(cfg)));
}
}, [cfg, baseline]);
useEffect(() => {
setConfigEditing(isDirty);
return () => setConfigEditing(false);
}, [isDirty, setConfigEditing]);
async function refreshMCPTools(cancelled = false) {
try {
const r = await fetch(`/webui/api/tools${q}`);
if (!r.ok) throw new Error('Failed to load tools');
const data = await r.json();
if (!cancelled) {
setMcpTools(Array.isArray(data?.mcp_tools) ? data.mcp_tools : []);
}
} catch {
if (!cancelled) setMcpTools([]);
}
}
useEffect(() => {
let cancelled = false;
void refreshMCPTools(cancelled);
return () => {
cancelled = true;
};
}, [q]);
function updateMCPServerField(name: string, field: string, value: any) {
setCfg((v) => setPath(v, `tools.mcp.servers.${name}.${field}`, value));
}
function addMCPServer() {
const name = newMCPServerName.trim();
if (!name) return;
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (!next.tools || typeof next.tools !== 'object') next.tools = {};
if (!next.tools.mcp || typeof next.tools.mcp !== 'object') {
next.tools.mcp = { enabled: true, request_timeout_sec: 20, servers: {} };
}
if (!next.tools.mcp.servers || typeof next.tools.mcp.servers !== 'object' || Array.isArray(next.tools.mcp.servers)) {
next.tools.mcp.servers = {};
}
if (!next.tools.mcp.servers[name]) {
next.tools.mcp.servers[name] = {
enabled: true,
transport: 'stdio',
command: '',
args: [],
env: {},
working_dir: '',
description: '',
package: '',
};
}
return next;
});
setNewMCPServerName('');
}
async function removeMCPServer(name: string) {
const ok = await ui.confirmDialog({
title: t('configDeleteMCPServerConfirmTitle'),
message: t('configDeleteMCPServerConfirmMessage', { name }),
danger: true,
confirmText: t('delete'),
});
if (!ok) return;
setCfg((v) => {
const next = JSON.parse(JSON.stringify(v || {}));
if (next?.tools?.mcp?.servers && typeof next.tools.mcp.servers === 'object') {
delete next.tools.mcp.servers[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 || '';
}
return '';
}
async function installMCPServerPackage(name: string, server: any) {
const defaultPkg = inferMCPPackage(server);
const pkg = await ui.promptDialog({
title: t('configMCPInstallTitle'),
message: t('configMCPInstallMessage', { name }),
inputPlaceholder: defaultPkg || t('configMCPInstallPlaceholder'),
initialValue: defaultPkg,
confirmText: t('install'),
});
const packageName = String(pkg || '').trim();
if (!packageName) return;
ui.showLoading(t('configMCPInstalling'));
try {
const r = await fetch(`/webui/api/mcp/install${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ package: packageName }),
});
const text = await r.text();
if (!r.ok) {
await ui.notify({ title: t('configMCPInstallFailedTitle'), message: text || t('configMCPInstallFailedMessage') });
return;
}
let data: any = null;
try {
data = JSON.parse(text);
} catch {
data = null;
}
if (data?.bin_path) {
updateMCPServerField(name, 'command', data.bin_path);
updateMCPServerField(name, 'args', []);
updateMCPServerField(name, 'package', packageName);
} else {
updateMCPServerField(name, 'package', packageName);
}
await ui.notify({
title: t('configMCPInstallDoneTitle'),
message: data?.bin_path
? t('configMCPInstallDoneMessage', { package: packageName, bin: data.bin_path })
: (text || t('configMCPInstallDoneFallback')),
});
} finally {
ui.hideLoading();
}
}
async function saveConfig() {
try {
const payload = cfg;
const submit = async (confirmRisky: boolean) => {
const body = confirmRisky ? { ...payload, confirm_risky: true } : payload;
return ui.withLoading(async () => {
const r = await fetch(`/webui/api/config${q}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const text = await r.text();
let data: any = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
return { ok: r.ok, text, data };
}, t('saving'));
};
let result = await submit(false);
if (!result.ok && result.data?.requires_confirm) {
const changedFields = Array.isArray(result.data?.changed_fields) ? result.data.changed_fields.join(', ') : '';
const ok = await ui.confirmDialog({
title: t('configRiskyChangeConfirmTitle'),
message: t('configRiskyChangeConfirmMessage', { fields: changedFields || '-' }),
danger: true,
confirmText: t('saveChanges'),
});
if (!ok) return;
result = await submit(true);
}
if (!result.ok) {
throw new Error(result.data?.error || result.text || 'save failed');
}
await ui.notify({ title: t('saved'), message: t('configSaved') });
setBaseline(JSON.parse(JSON.stringify(payload)));
setConfigEditing(false);
await loadConfig(true);
await refreshMCPTools();
} catch (e) {
await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${e}` });
}
}
return (
<div className="p-4 md:p-8 w-full space-y-6">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{t('mcpServices')}</h1>
<p className="text-sm text-zinc-500 mt-1">{t('mcpServicesHint')}</p>
</div>
<button
onClick={async () => { setBaseline(null); await loadConfig(true); }}
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" /> {t('reload')}
</button>
</div>
<div className="flex items-center justify-end gap-3 flex-wrap">
<button
onClick={saveConfig}
disabled={!isDirty}
className="brand-button flex items-center gap-2 px-4 py-2 text-white rounded-xl text-sm font-medium transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" /> {t('saveChanges')}
</button>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configMCPServers')}</div>
<div className="flex items-center gap-2">
<input value={newMCPServerName} onChange={(e)=>setNewMCPServerName(e.target.value)} placeholder={t('configNewMCPServerName')} className="px-2 py-1 rounded-lg bg-zinc-900/70 border border-zinc-700 text-xs" />
<button onClick={addMCPServer} className="brand-button px-2 py-1 rounded-lg text-xs text-white">{t('add')}</button>
</div>
</div>
<div className="space-y-2">
{Object.entries((((cfg as any)?.tools?.mcp?.servers) || {}) as Record<string, any>).map(([name, server]) => (
<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>
<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>
</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>
)}
</div>
</div>
<div className="brand-card-subtle rounded-2xl border border-zinc-800 p-3 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-sm font-semibold text-zinc-200">{t('configMCPDiscoveredTools')}</div>
<div className="text-xs text-zinc-500">{t('configMCPDiscoveredToolsCount', { count: mcpTools.length })}</div>
</div>
<div className="space-y-2">
{mcpTools.map((tool) => (
<div key={tool.name} className="rounded-xl border border-zinc-800 bg-zinc-900/30 p-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="font-mono text-xs text-zinc-200">{tool.name}</div>
<div className="text-[11px] text-zinc-500">
{(tool.mcp?.server || '-')}{' · '}{(tool.mcp?.remote_tool || '-')}
</div>
</div>
{tool.description && (
<div className="mt-2 text-xs text-zinc-400">{tool.description}</div>
)}
</div>
))}
{mcpTools.length === 0 && (
<div className="text-xs text-zinc-500">{t('configNoMCPDiscoveredTools')}</div>
)}
</div>
</div>
</div>
);
};
export default MCP;