Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
# Test artifacts
tests/integration/testdata/

# Binary
openclaw-go
pkg-helper
# Binary (anchored to repo root so we don't accidentally ignore
# the cmd/pkg-helper/ source directory, which breaks Docker builds
# on uploaders that pattern-match .gitignore against the whole tree
# — notably `railway up`).
/openclaw-go
/pkg-helper
/goclaw.exe
/goclaw-local.exe

# Ad-hoc debug probes live here — never commit (they import internal/
# packages from outside cmd/ and only exist for one-shot diagnostics).
/tmp-reset-bot/
/tmp-probe*/

# IDE
.idea/
Expand Down
18 changes: 18 additions & 0 deletions .railwayignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
.git
.github
.vscode
.idea
.claude
ui/desktop
ui/simple-saas
ui/web/node_modules
ui/web/dist
**/node_modules
plans
skills-store
docs
tests
tmp
tmp-*
*.exe
._*
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ require (
github.com/slack-go/slack v0.19.0
github.com/spf13/cobra v1.10.2
github.com/titanous/json5 v1.0.0
github.com/wailsapp/wails/v2 v2.11.0
github.com/wailsapp/wails/v2 v2.12.0
github.com/zalando/go-keyring v0.2.8
go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4
go.opentelemetry.io/otel v1.40.0
Expand All @@ -51,6 +51,7 @@ require (
require (
cel.dev/expr v0.25.1 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/akutz/memconn v0.1.0 // indirect
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
Expand Down
10 changes: 6 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc=
filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
Expand Down Expand Up @@ -501,8 +503,8 @@ github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6N
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
Expand Down Expand Up @@ -647,8 +649,8 @@ gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvs
gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho=
honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
Expand Down
4 changes: 3 additions & 1 deletion internal/agent/loop_mcp_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ func (l *Loop) getUserMCPTools(ctx context.Context, userID string) []tools.Tool
// Create BridgeTools pointing to user's connection and register in the
// shared tool registry so ExecuteWithContext can resolve them by name.
reg, _ := l.tools.(*tools.Registry)
hints := mcpbridge.ParseToolHints(srv.Settings)
for _, mcpTool := range entry.MCPTools() {
bt := mcpbridge.NewBridgeTool(srv.Name, mcpTool, entry.ClientPtr(), srv.ToolPrefix, srv.TimeoutSec, entry.Connected(), srv.ID, l.mcpGrantChecker)
bt := mcpbridge.NewBridgeTool(srv.Name, mcpTool, entry.ClientPtr(), srv.ToolPrefix, srv.TimeoutSec, entry.Connected(), srv.ID, l.mcpGrantChecker).
WithHints(hints.Global, hints.HintFor(mcpTool.Name))
// Register in registry so ExecuteWithContext can find them.
// Skip if already registered (another user loaded this server with same tool names).
if reg != nil {
Expand Down
8 changes: 7 additions & 1 deletion internal/gateway/methods/channel_instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,15 @@ func maskInstance(inst store.ChannelInstanceData) map[string]any {
}

// isValidChannelType checks if the channel type is supported.
//
// Keep this list in sync with the HTTP twin in internal/http/channel_instances.go
// and with CHANNEL_TYPES in ui/web/src/constants/channels.ts. When the two
// backend switches drift (as happened with facebook/pancake/bitrix24), the
// WS-driven UI rejects channels the HTTP API accepts, and the dropdown offers
// channels neither API accepts.
func isValidChannelType(ct string) bool {
switch ct {
case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu":
case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake", "bitrix24":
return true
}
return false
Expand Down
26 changes: 26 additions & 0 deletions internal/gateway/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,17 @@ func (s *Server) BuildMux() *http.ServeMux {
}

// Embedded web UI (built with -tags embedui). Catch-all after all API routes.
// When the build does NOT include the embedui tag, webui.Handler() returns nil
// and there's no handler for "/" — http.ServeMux would then return an opaque
// 404 for the root URL, confusing operators who open the deployed URL in a
// browser to check the service. Install a minimal JSON index handler in that
// case so the root responds with something useful (and any unmatched path
// still returns 404, just with a JSON body).
if h := webui.Handler(); h != nil {
mux.Handle("/", h)
slog.Info("serving embedded web UI")
} else {
mux.HandleFunc("/", s.handleIndex)
}

s.mux = mux
Expand Down Expand Up @@ -372,6 +380,24 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, `{"status":"ok","protocol":%d}`, protocol.ProtocolVersion)
}

// handleIndex is the fallback "/" handler when no embedded web UI is present.
// It returns a small JSON service-info document for exact-match "/" requests
// and a JSON 404 for everything else — http.ServeMux routes "/" as a
// catch-all, so unrelated paths fall through here too.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/" {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"not found"}`))
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w,
`{"service":"goclaw","status":"ok","protocol":%d,`+
`"endpoints":["/health","/v1/chat/completions","/v1/responses","/v1/tools/invoke","/ws"]}`,
protocol.ProtocolVersion)
}

// clientIP extracts the real client IP from the request, checking proxy headers first.
func clientIP(r *http.Request) string {
if ip := r.Header.Get("X-Real-IP"); ip != "" {
Expand Down
6 changes: 5 additions & 1 deletion internal/http/channel_instances.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,9 +554,13 @@ func (h *ChannelInstancesHandler) handleResolveContacts(w http.ResponseWriter, r
}

// isValidChannelType checks if the channel type is supported.
//
// Keep this list in sync with the WS twin in
// internal/gateway/methods/channel_instances.go and with CHANNEL_TYPES in
// ui/web/src/constants/channels.ts.
func isValidChannelType(ct string) bool {
switch ct {
case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake":
case "telegram", "discord", "slack", "whatsapp", "zalo_oa", "zalo_personal", "feishu", "facebook", "pancake", "bitrix24":
return true
}
return false
Expand Down
59 changes: 46 additions & 13 deletions internal/mcp/bridge_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,18 @@ import (
// The client pointer is loaded atomically from clientPtr to support
// safe reconnection without data races.
type BridgeTool struct {
serverName string
serverID uuid.UUID // MCP server ID (for grant recheck)
toolName string // original MCP tool name
registeredName string // may include prefix: "{prefix}__{toolName}"
description string
inputSchema map[string]any // JSON Schema for parameters
requiredSet map[string]bool
clientPtr *atomic.Pointer[mcpclient.Client] // shared with serverState for atomic swap on reconnect
timeoutSec int
connected *atomic.Bool
grantChecker GrantChecker // for runtime grant recheck (nil = skip check)
serverName string
serverID uuid.UUID // MCP server ID (for grant recheck)
toolName string // original MCP tool name
registeredName string // may include prefix: "{prefix}__{toolName}"
description string
descriptionSuffix string // admin-authored hints appended to description (see WithHints)
inputSchema map[string]any // JSON Schema for parameters
requiredSet map[string]bool
clientPtr *atomic.Pointer[mcpclient.Client] // shared with serverState for atomic swap on reconnect
timeoutSec int
connected *atomic.Bool
grantChecker GrantChecker // for runtime grant recheck (nil = skip check)
}

// NewBridgeTool creates a BridgeTool from an MCP Tool definition.
Expand Down Expand Up @@ -92,10 +93,42 @@ func ensureMCPPrefix(prefix, serverName string) string {
return prefix
}

func (t *BridgeTool) Name() string { return t.registeredName }
func (t *BridgeTool) Description() string { return t.description }
func (t *BridgeTool) Name() string { return t.registeredName }
func (t *BridgeTool) Description() string {
if t.descriptionSuffix == "" {
return t.description
}
return t.description + t.descriptionSuffix
}
func (t *BridgeTool) Parameters() map[string]any { return t.inputSchema }

// WithHints attaches admin-authored description hints to this tool. Hints are
// appended to Description() so the LLM sees server-specific quirks (e.g. "no
// trailing semicolons in code args") without modifying the upstream MCP server.
// Empty global and toolHint render no suffix. Returns t for chaining.
//
// Wire hints from MCPServerData.Settings via ParseToolHints:
//
// hints := ParseToolHints(srv.Settings)
// bt := NewBridgeTool(...).WithHints(hints.Global, hints.HintFor(mcpTool.Name))
func (t *BridgeTool) WithHints(global, toolHint string) *BridgeTool {
g := strings.TrimSpace(global)
h := strings.TrimSpace(toolHint)
if g == "" && h == "" {
t.descriptionSuffix = ""
return t
}
var parts []string
if g != "" {
parts = append(parts, "[Server hint] "+g)
}
if h != "" {
parts = append(parts, "[Tool hint] "+h)
}
t.descriptionSuffix = "\n\n" + strings.Join(parts, "\n\n")
return t
}

// ServerName returns the name of the MCP server this tool belongs to.
func (t *BridgeTool) ServerName() string { return t.serverName }

Expand Down
51 changes: 51 additions & 0 deletions internal/mcp/bridge_tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,57 @@ func TestBridgeToolNaming(t *testing.T) {
}
}

func TestBridgeToolWithHints(t *testing.T) {
mcpTool := mcpgo.Tool{
Name: "search",
Description: "Run a search",
InputSchema: mcpgo.ToolInputSchema{Type: "object"},
}

// No hints → original description unchanged
bt := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil)
if bt.Description() != "Run a search" {
t.Errorf("expected unchanged description, got %q", bt.Description())
}

// Global hint only
bt2 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
WithHints("No trailing semicolons.", "")
got := bt2.Description()
if got != "Run a search\n\n[Server hint] No trailing semicolons." {
t.Errorf("global-only mismatch:\n%q", got)
}

// Per-tool hint only
bt3 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
WithHints("", "Use arrow func.")
if bt3.Description() != "Run a search\n\n[Tool hint] Use arrow func." {
t.Errorf("tool-only mismatch: %q", bt3.Description())
}

// Both hints — order: global then tool
bt4 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
WithHints("G.", "T.")
if bt4.Description() != "Run a search\n\n[Server hint] G.\n\n[Tool hint] T." {
t.Errorf("combined mismatch: %q", bt4.Description())
}

// Whitespace-only hints → treated as empty (no suffix)
bt5 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
WithHints(" \n ", "\t")
if bt5.Description() != "Run a search" {
t.Errorf("whitespace-only hints should render no suffix, got %q", bt5.Description())
}

// WithHints can be chained and reset by re-calling
bt6 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
WithHints("first", "hint")
bt6.WithHints("", "")
if bt6.Description() != "Run a search" {
t.Errorf("calling WithHints with empty should clear suffix, got %q", bt6.Description())
}
}

func TestIsPlaceholderValue(t *testing.T) {
// Should be detected as placeholder.
placeholders := []string{
Expand Down
48 changes: 45 additions & 3 deletions internal/mcp/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ func (m *Manager) Start(ctx context.Context) error {
errs = append(errs, fmt.Sprintf("%s: %v", name, err))
continue
}
if err := m.connectServer(ctx, name, cfg.Transport, cfg.Command, cfg.Args, cfg.Env, cfg.URL, headers, cfg.ToolPrefix, cfg.TimeoutSec, uuid.Nil); err != nil {
// Config-path servers have no DB-backed Settings, so no tool hints.
if err := m.connectServer(ctx, name, cfg.Transport, cfg.Command, cfg.Args, cfg.Env, cfg.URL, headers, cfg.ToolPrefix, cfg.TimeoutSec, uuid.Nil, ToolHints{}); err != nil {
slog.Warn("mcp.server.connect_failed", "server", name, "error", err)
errs = append(errs, fmt.Sprintf("%s: %v", name, err))
}
Expand Down Expand Up @@ -288,19 +289,20 @@ func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAc
// and applies tool allow/deny filtering from server grants.
func (m *Manager) connectAndFilter(ctx context.Context, rs *resolvedServer) error {
srv := rs.info.Server
hints := ParseToolHints(srv.Settings)

if m.pool != nil && !rs.hasUserCreds {
// Pool mode: acquire shared connection, create per-agent BridgeTools
tid := store.TenantIDFromContext(ctx)
if err := m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command,
rs.args, rs.env, srv.URL, rs.headers, srv.ToolPrefix, srv.TimeoutSec, srv.ID); err != nil {
rs.args, rs.env, srv.URL, rs.headers, srv.ToolPrefix, srv.TimeoutSec, srv.ID, hints); err != nil {
return err
}
} else {
// Per-agent mode: create per-agent connection
if err := m.connectServer(ctx, srv.Name, srv.Transport, srv.Command,
rs.args, rs.env, srv.URL, rs.headers,
srv.ToolPrefix, srv.TimeoutSec, srv.ID); err != nil {
srv.ToolPrefix, srv.TimeoutSec, srv.ID, hints); err != nil {
return err
}
}
Expand Down Expand Up @@ -595,3 +597,43 @@ func requireUserCreds(settings json.RawMessage) bool {
_ = json.Unmarshal(settings, &s)
return s.RequireUserCredentials
}

// ToolHints carries admin-authored description hints for MCP tools.
// Stored under MCPServerData.Settings.tool_hints as JSONB:
//
// {
// "tool_hints": {
// "global": "...",
// "tools": { "<tool_name>": "..." }
// }
// }
//
// The hints are appended to a tool's description so the LLM sees server-specific
// quirks (e.g. "no trailing semicolons in code args") without modifying the MCP
// server itself. Empty Global/Tools render no suffix.
type ToolHints struct {
Global string `json:"global,omitempty"`
Tools map[string]string `json:"tools,omitempty"`
}

// ParseToolHints extracts tool description hints from an MCP server's Settings JSONB.
// Returns a zero-value ToolHints (no hints) if settings are empty or malformed.
// Safe to call with nil — never panics.
func ParseToolHints(settings json.RawMessage) ToolHints {
if len(settings) == 0 {
return ToolHints{}
}
var s struct {
ToolHints ToolHints `json:"tool_hints"`
}
_ = json.Unmarshal(settings, &s)
return s.ToolHints
}

// HintFor returns the per-tool hint for toolName, or empty string if none.
func (h ToolHints) HintFor(toolName string) string {
if h.Tools == nil {
return ""
}
return h.Tools[toolName]
}
Loading
Loading