Files
SYNITYandDangTinh311 3b8bc1bc4d feat(bitrix24): replace two MCP text inputs with a filtered dropdown (#1392)
* feat(bitrix24): replace two MCP text inputs with a filtered dropdown [B24:2794]

Bitrix24 channel creation used to demand two hand-typed strings —
mcp_server_name and mcp_base_url — plus zero indication of which MCP
servers can actually auto-onboard. Typos silently disabled provisioning
and admin had to know which servers implement /api/auto-onboard.

Ship a single dropdown backed by mcp_servers.require_user_credentials,
plus the machinery to make it work end-to-end.

Phase 1 — DB & store
* Promote require_user_credentials from settings JSONB to a top-level
  column on mcp_servers (PG migration 000089, SQLite migration v54).
* Backfill from existing settings blobs so no admin needs to re-tick.
* Add MCPServerData.RequireUserCredentials to the Go store layer, plumb
  through Create / Get / GetByName / List / Update on both stores,
  extend the export DTO, and add require_user_credentials to the HTTP
  allowlist.
* Bump RequiredSchemaVersion 87 -> 89 (jumping 88, which was on disk
  but not wired) and SchemaVersion 53 -> 54 with an
  idempotentColumnMigration guard.

Phase 2 — Bitrix24 channel factory
* Add MCPServerID (UUID string) to bitrix24 InstanceConfig, keep
  MCPServerName + MCPBaseURL as legacy fallback with a "deprecated"
  doc comment.
* Factory validation accepts either mcp_server_id alone or the legacy
  pair; half-config still fails fast.
* initMCPProvisioner prefers GetServer(id) and sources the base URL
  from mcp_servers.url when the id path is used. Legacy name path
  unchanged so pre-migration configs keep working.
* Log line now carries mcp_server_id + require_user_credentials so
  operators can eyeball the wiring.

Phase 3 — Frontend types & MCP form
* Add optional top-level require_user_credentials to MCPServerData /
  MCPServerInput in both ui/web and ui/desktop/frontend types.
* mcp-form-dialog reads the top-level flag first and falls back to
  settings.require_user_credentials so cached responses from
  pre-upgrade backends still render correctly.
* On submit send both the top-level flag AND the legacy settings
  entry so mid-rollout backends stay consistent.

Phase 4 — Bitrix24 channel form dropdown
* New mcp-select field type + MCPServerSelect component. Uses the
  shared useMCP() react-query cache and filters client-side to
  servers whose require_user_credentials is true (OR settings
  JSONB during the migration window).
* Explicit "None (disable MCP provisioning)" option so admins can
  clear the binding without editing config JSON.
* Legacy mcp_server_name / mcp_base_url text inputs kept in the
  Advanced panel, relabelled "(legacy)" with pointer help text.

Phase 5 — channel_instances.config backfill
* PG migration 000090 and SQLite migration v55 rewrite existing
  bitrix24 channel_instances.config to add mcp_server_id by
  resolving mcp_server_name against mcp_servers, tenant-scoped
  via agents.tenant_id (channel_instances doesn't carry tenant_id
  directly).
* Idempotent — only touches rows already carrying
  mcp_server_name that lack mcp_server_id. Legacy keys are left
  in place so provisioner.go can still fall back for unmigrated
  or future-created legacy configs.
* down.sql drops the mcp_server_id key. Provisioner immediately
  reverts to the legacy fallback path.

Tests
* provisioner_test.go: three new cases exercise the mcp_server_id
  path (invalid UUID string, valid UUID with missing row, valid
  UUID with a per-user row). fakeMCPStore gains a serversByID
  map and a real GetServer implementation.
* Existing legacy-config tests unchanged and still green.

Verification
* go build ./... && go build -tags sqliteonly ./...
* go vet ./internal/mcp/... ./internal/channels/bitrix24/...
    ./internal/store/... ./internal/http/...
* go test ./internal/mcp/... ./internal/channels/bitrix24/...  -> ok
* Live-tested against a local docker image on the goclaw-deploy
  postgres. Migrations 89 + 90 applied cleanly. Three existing
  bitrix24 channels (bitrix-sales / nguyen-dao-openline / tieu-vi)
  had their configs backfilled with the b24-syn-mcp UUID and the
  provisioner boots with require_user_credentials=true. UI dropdown
  correctly shows only b24-syn-mcp (the only server with the flag
  ticked) alongside a "None" clearer option.

Surface parity
* Gateway server: store + factory + provisioner + HTTP allowlist.
* API contract: adds require_user_credentials + mcp_server_id
  as optional fields on existing routes. No new endpoints.
* Web UI: MCP form + Bitrix24 channel form + shared types.
* CLI/runtime package: N/A because no CLI subcommand reads the
  mcp_server_id field.

* fix(bitrix24): derive auto-onboard base URL from mcp_servers.url origin [B24:2794]

The Phase 2 refactor swapped provisioner base-URL sourcing from the
legacy per-channel MCPBaseURL config field (which historically stored the
MCP server's ORIGIN, e.g. https://mcp.example.com) to mcp_servers.url,
which stores the JSON-RPC ENDPOINT the agent loop dials (e.g.
https://mcp.example.com/mcp). The two are semantically different but
share a single column.

mcp_client.newMCPClient then appends "/api/auto-onboard" to whatever
baseURL it receives, so the id-path started POSTing to
".../mcp/api/auto-onboard" — 404 for every per-user credential mint and
refresh. Existing users kept working only until their cached access
tokens expired.

Fix: derive the origin (scheme://host[:port]) from server.URL before
handing it to the auto-onboard client. The legacy path is untouched
because MCPBaseURL from channel config is already the origin.

  https://b24-mcp-dev.synity.so/mcp   ->  https://b24-mcp-dev.synity.so
  https://mcp.example.com/mcp/        ->  https://mcp.example.com
  https://mcp.example.com             ->  https://mcp.example.com

Table-driven test covers six shapes plus four error cases (empty,
whitespace-only, no scheme, no host). Updated the existing
TestInitMCPProvisioner_MCPServerID fixture to seed a URL with the /mcp
subpath so it regression-guards the same code path.

Verified live: user 614 sent a message that triggered the expired-cred
refresh branch; goclaw logged "self-refreshed user credentials
created=false" and the agent immediately reported
"mcp.user_tools_loaded user=614 tools=2". Before this fix the same event
logged 'auto-onboard failed: mcp auto-onboard: 404 Not Found'.

Surface parity:
  - Gateway server: provisioner + one new helper (deriveAutoOnboardBaseURL).
  - API contract: N/A because the wire shape hasn't changed.
  - Web UI: N/A because the UI still writes mcp_server_id verbatim.
  - CLI/runtime: N/A because no CLI reads the derived base URL.

---------

Co-authored-by: DangTinh311 <dangtinh31193@gmail.com>
2026-07-08 19:23:21 +07:00

1009 lines
36 KiB
Go

package mcp
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"sync/atomic"
"time"
mcpclient "github.com/mark3labs/mcp-go/client"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/google/uuid"
)
const (
healthCheckInterval = 30 * time.Second
healthFailThreshold = 3 // consecutive ping failures before marking disconnected
initialBackoff = 2 * time.Second
maxBackoff = 60 * time.Second
maxReconnectAttempts = 10
reconnectCooldown = 5 * time.Minute // wait after exhausting reconnect attempts before retrying
// mcpToolInlineMaxCount is the threshold above which MCP tools switch
// to search mode (deferred loading via mcp_tool_search) instead of
// being registered inline in the tool registry.
mcpToolInlineMaxCount = 40
)
// ServerStatus reports the connection status of an MCP server.
type ServerStatus struct {
Name string `json:"name"`
Transport string `json:"transport"`
Connected bool `json:"connected"`
ToolCount int `json:"tool_count"`
Error string `json:"error,omitempty"`
}
// connParams stores connection parameters needed to re-establish a dead connection.
// Populated during initial connectAndDiscover and used by tryReconnect.
type connParams struct {
command string
args []string
env map[string]string
url string
headers map[string]string
}
// serverState tracks a single MCP server connection.
//
// Dual-pointer design for the MCP client:
// - client: direct pointer used by healthLoop (single goroutine, no contention).
// - clientPtr: atomic pointer shared with all BridgeTools via NewBridgeTool.
// BridgeTools call clientPtr.Load() in Execute for race-safe access.
//
// On reconnect, fullReconnect() updates BOTH: ss.client for healthLoop and
// ss.clientPtr.Store() for BridgeTools. The old client is closed AFTER the swap.
type serverState struct {
name string
transport string
client *mcpclient.Client // direct ref for health checks (single-goroutine access)
clientPtr atomic.Pointer[mcpclient.Client] // shared atomic ref for BridgeTools (multi-goroutine safe)
connected atomic.Bool
toolNames []string // registered tool names in the registry
timeoutSec int
cancel context.CancelFunc
conn connParams // connection params for reconnect
mu sync.Mutex
reconnAttempts int
healthFailures int // consecutive ping failures (resets on success)
lastErr string
// reconnPending is set when a BridgeTool detects the server reset its
// session lifecycle (FastMCP-style "tools/call invalid during session
// initialization" error) and force-reconnect is in flight. The health
// loop must skip its ping while pending, otherwise a server that still
// answers `ping` in "initializing" state would clobber connected=true
// before the fresh Initialize completes — leaving the pool to keep
// serving the dead session.
reconnPending atomic.Bool
}
// Manager orchestrates MCP server connections and tool registration.
// Supports two sources:
// - Config-based: reads from config.MCPServerConfig map (shared across all agents)
// - DB-backed: queries MCPServerStore per agent+user for permission-filtered servers
//
// When total MCP tool count exceeds mcpToolInlineMaxCount, the manager
// enters hybrid search mode: the first mcpToolInlineMaxCount tools stay
// registered inline, while excess tools move to deferredTools and are
// discovered via mcp_tool_search. Tools are activated on demand via
// ActivateTools().
type Manager struct {
mu sync.RWMutex
servers map[string]*serverState
registry *tools.Registry
// Config-based servers
configs map[string]*config.MCPServerConfig
// DB-backed servers
store store.MCPServerStore
// Grant checker for runtime grant verification (nil = skip check)
grantChecker GrantChecker
// Shared connection pool (nil = config-only mode)
pool *Pool
poolServers map[string]struct{} // server names acquired from pool (for cleanup)
poolToolNames map[string][]string // per-agent tool names for pool-backed servers
poolKeys map[string]string // server name → pool compound key (tenantID/name) for Release
// Search mode: deferred tools not registered in registry
deferredTools map[string]*BridgeTool // registeredName → BridgeTool
activatedTools map[string]struct{} // tracks activated tool names for group:mcp
searchMode bool
// User-credential servers: servers requiring per-user credentials, stored during
// LoadForAgent("") for later per-request tool resolution. These servers are NOT
// connected at startup — connections are created per-user via pool.AcquireUser().
userCredServers []store.MCPAccessInfo
// oauthTokenProvider resolves a live Bearer token for OAuth-enabled MCP servers.
// nil = OAuth token injection disabled.
oauthTokenProvider OAuthTokenProvider
}
// OAuthTokenProvider retrieves a valid OAuth Bearer token for an MCP server.
// userID="" means global token; non-empty means per-user token.
type OAuthTokenProvider interface {
GetValidToken(ctx context.Context, serverID, tenantID uuid.UUID, userID string) (string, error)
}
// ManagerOption configures the Manager.
type ManagerOption func(*Manager)
// WithConfigs sets static MCP server configs from the config file.
func WithConfigs(cfgs map[string]*config.MCPServerConfig) ManagerOption {
return func(m *Manager) {
m.configs = cfgs
}
}
// SetConfigs replaces the static server config map on an already-constructed Manager.
// This is used by the gateway to populate configs from the database after the store
// is initialised, before calling Start.
func (m *Manager) SetConfigs(cfgs map[string]*config.MCPServerConfig) {
slog.Debug("mcp.Manager.SetConfigs: applying configs", "count", len(cfgs))
m.configs = cfgs
slog.Debug("mcp.Manager.SetConfigs: configs applied successfully", "count", len(cfgs))
}
// WithStore sets the MCPServerStore for DB-backed MCP server loading.
func WithStore(s store.MCPServerStore) ManagerOption {
return func(m *Manager) {
m.store = s
}
}
// SetStore sets the MCP store on an already-constructed Manager.
// Use this when the store is not yet available at construction time.
func (m *Manager) SetStore(s store.MCPServerStore) {
m.store = s
}
// WithPool sets a shared connection pool for MCP servers.
// When set, LoadForAgent uses the pool instead of creating per-agent connections.
func WithPool(p *Pool) ManagerOption {
return func(m *Manager) {
m.pool = p
}
}
// WithGrantChecker sets the grant checker for runtime grant verification.
// When set, BridgeTool.Execute rechecks grants before executing tools.
func WithGrantChecker(gc GrantChecker) ManagerOption {
return func(m *Manager) {
m.grantChecker = gc
}
}
// WithOAuthTokenProvider sets the OAuth token provider for Bearer token injection.
func WithOAuthTokenProvider(p OAuthTokenProvider) ManagerOption {
return func(m *Manager) {
m.oauthTokenProvider = p
}
}
// NewManager creates a new MCP Manager.
func NewManager(registry *tools.Registry, opts ...ManagerOption) *Manager {
m := &Manager{
servers: make(map[string]*serverState),
registry: registry,
}
for _, opt := range opts {
opt(m)
}
return m
}
// Start connects to all config-file MCP servers.
// Non-fatal: logs warnings for servers that fail to connect and continues.
func (m *Manager) Start(ctx context.Context) error {
slog.Debug("mcp.Manager.Start: called", "configs", len(m.configs))
if len(m.configs) == 0 {
slog.Debug("mcp.Manager.Start: no configs, returning early")
return nil
}
var errs []string
for name, cfg := range m.configs {
if !cfg.IsEnabled() {
slog.Debug("mcp.server.disabled", "server", name)
continue
}
slog.Debug("mcp.Manager.Start: starting server", "name", name, "transport", cfg.Transport)
// Config-path servers have no DB ID — pass uuid.Nil
headers, err := resolveEnvVars(cfg.Headers)
if err != nil {
slog.Warn("security.mcp.env_var_rejected", "server", name, "err", err)
errs = append(errs, fmt.Sprintf("%s: %v", name, err))
continue
}
// Config-path servers have no DB-backed Settings, so no tool hints.
// Also no grant-based tool filtering (allow/deny are nil) — config
// trust model is that the operator gates which servers are enabled.
if err := m.connectServer(ctx, name, cfg.Transport, cfg.Command, cfg.Args, cfg.Env, cfg.URL, headers, cfg.ToolPrefix, cfg.TimeoutSec, uuid.Nil, ToolHints{}, nil, nil); err != nil {
slog.Warn("mcp.server.connect_failed", "server", name, "error", err)
errs = append(errs, fmt.Sprintf("%s: %v", name, err))
} else {
m.mu.RLock()
toolCount := 0
if ss, ok := m.servers[name]; ok {
toolCount = len(ss.toolNames)
}
m.mu.RUnlock()
slog.Debug("mcp.Manager.Start: server started", "name", name, "tools", toolCount)
}
}
totalTools := len(m.ToolNames())
slog.Debug("mcp.Manager.Start: fully started", "total_tools", totalTools, "errors", len(errs))
if len(errs) > 0 {
return fmt.Errorf("some MCP servers failed to connect: %s", joinErrors(errs))
}
return nil
}
// resolvedServer holds a server config with merged credentials ready for connection.
type resolvedServer struct {
info store.MCPAccessInfo
args []string
env map[string]string
headers map[string]string
hasUserCreds bool
}
// resolveServerCredentials merges server defaults with per-user credentials.
// Returns nil if the server should be skipped (disabled or missing required creds).
func (m *Manager) resolveServerCredentials(ctx context.Context, info store.MCPAccessInfo, userID string) *resolvedServer {
srv := info.Server
if !srv.Enabled {
return nil
}
var contextCreds *store.MCPContextCredentials
if contextStore, ok := m.store.(store.MCPContextAdminStore); ok {
for _, scope := range store.ChannelContextScopeChainFromContext(ctx) {
if creds, _ := contextStore.GetContextCredentialsForScope(ctx, scope, srv.ID); creds != nil {
contextCreds = creds
}
}
}
// Skip server if it requires scoped/user credentials and none are present.
// Prefer the top-level column (Phase 89 backfilled from settings JSONB);
// fall back to the legacy JSONB entry for cases where a caller mutated
// settings directly without touching the column.
if srv.RequireUserCredentials || requireUserCreds(srv.Settings) {
if userID == "" {
return nil
}
uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID)
hasContextCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0)
if !hasContextCreds && (uc == nil || (uc.APIKey == "" && len(uc.Headers) == 0 && len(uc.Env) == 0)) {
slog.Debug("mcp.skip_no_user_credentials", "server", srv.Name, "user", userID)
return nil
}
}
args := jsonBytesToStringSlice(srv.Args)
env := jsonBytesToStringMap(srv.Env)
headers, err := resolveEnvVars(jsonBytesToStringMap(srv.Headers))
if err != nil {
slog.Warn("security.mcp.env_var_rejected", "server", srv.Name, "err", err)
return nil
}
oauthActive := isOAuthActive(srv.Settings)
// Inject APIKey into headers if present — ONLY for non-OAuth servers.
// For OAuth servers the Authorization MUST come from the OAuth token; using the
// server-level api_key as a fallback would expose tools before authorization.
if !oauthActive && srv.APIKey != "" && headers["Authorization"] == "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + srv.APIKey
}
if contextCreds != nil {
if contextCreds.APIKey != "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + contextCreds.APIKey
}
for k, v := range contextCreds.Headers {
if headers == nil {
headers = make(map[string]string)
}
headers[k] = v
}
for k, v := range contextCreds.Env {
if env == nil {
env = make(map[string]string)
}
env[k] = v
}
}
// Merge per-user credentials (user overrides server defaults)
if userID != "" && m.store != nil {
if userCreds, err := m.store.GetUserCredentials(ctx, srv.ID, userID); err == nil && userCreds != nil {
if userCreds.APIKey != "" {
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + userCreds.APIKey
}
for k, v := range userCreds.Headers {
if headers == nil {
headers = make(map[string]string)
}
headers[k] = v
}
for k, v := range userCreds.Env {
if env == nil {
env = make(map[string]string)
}
env[k] = v
}
}
}
// OAuth-enabled servers: the OAuth token is the ONLY source of Authorization.
// Require a valid token — if none is available (not authorized yet / refresh
// failed / OAuth subsystem absent), skip the server so it is NOT loaded into the
// shared registry with the server-level credential as a fallback.
if oauthActive {
if m.oauthTokenProvider == nil {
slog.Debug("mcp.skip_oauth_no_provider", "server", srv.Name)
return nil
}
tenantID := store.TenantIDFromContext(ctx)
oauthUserID := ""
if srv.RequireUserCredentials || requireUserCreds(srv.Settings) {
oauthUserID = userID
}
token, err2 := m.oauthTokenProvider.GetValidToken(ctx, srv.ID, tenantID, oauthUserID)
if err2 != nil || token == "" {
slog.Debug("mcp.skip_oauth_no_token", "server", srv.Name, "user", oauthUserID, "error", err2)
return nil
}
if headers == nil {
headers = make(map[string]string)
}
headers["Authorization"] = "Bearer " + token
}
// Per-user credentials change connection params → can't share pool connection.
// Fall back to per-agent mode when user has custom credentials.
hasUserCreds := contextCreds != nil && (contextCreds.APIKey != "" || len(contextCreds.Headers) > 0 || len(contextCreds.Env) > 0)
if userID != "" && m.store != nil {
if uc, _ := m.store.GetUserCredentials(ctx, srv.ID, userID); uc != nil && (uc.APIKey != "" || len(uc.Headers) > 0 || len(uc.Env) > 0) {
hasUserCreds = true
}
}
return &resolvedServer{
info: info,
args: args,
env: env,
headers: headers,
hasUserCreds: hasUserCreds,
}
}
// connectAndFilter establishes the MCP connection (pool or per-agent mode).
// Tool allow/deny filtering from server grants is applied upfront inside
// registerBridgeTools / registerPoolBridgeTools so non-allowed tools never
// reach the registry (and thus never reach the LLM).
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)
return m.connectViaPool(ctx, tid, srv.Name, srv.Transport, srv.Command,
rs.args, rs.env, srv.URL, rs.headers, srv.ToolPrefix, srv.TimeoutSec, srv.ID, hints,
rs.info.ToolAllow, rs.info.ToolDeny)
}
// Per-agent mode: create per-agent connection
return m.connectServer(ctx, srv.Name, srv.Transport, srv.Command,
rs.args, rs.env, srv.URL, rs.headers,
srv.ToolPrefix, srv.TimeoutSec, srv.ID, hints,
rs.info.ToolAllow, rs.info.ToolDeny)
}
// LoadForAgent connects MCP servers accessible by a specific agent+user.
// Previously registered MCP tools for this manager are cleared and reloaded.
func (m *Manager) LoadForAgent(ctx context.Context, agentID uuid.UUID, userID string) error {
if m.store == nil {
return nil
}
accessible, err := m.store.ListAccessible(ctx, agentID, userID)
if err != nil {
return fmt.Errorf("list accessible MCP servers: %w", err)
}
// Unregister all existing MCP tools first
m.unregisterAllTools()
m.userCredServers = nil
for _, info := range accessible {
// When loading at startup (userID=""), store servers requiring per-user
// credentials for later per-request resolution instead of skipping them.
if userID == "" && (info.Server.RequireUserCredentials || requireUserCreds(info.Server.Settings)) && info.Server.Enabled {
m.userCredServers = append(m.userCredServers, info)
slog.Debug("mcp.server.deferred_user_creds", "server", info.Server.Name)
continue
}
rs := m.resolveServerCredentials(ctx, info, userID)
if rs == nil {
continue
}
if err := m.connectAndFilter(ctx, rs); err != nil {
slog.Warn("mcp.server.connect_failed", "server", info.Server.Name, "error", err)
}
}
// Check if we should enter search mode (too many tools to inline)
m.maybeEnterSearchMode()
return nil
}
// maybeEnterSearchMode partially defers MCP tools when total count exceeds
// the inline threshold. The first mcpToolInlineMaxCount tools stay registered
// inline; the rest are moved to deferredTools and discovered via mcp_tool_search.
func (m *Manager) maybeEnterSearchMode() {
allNames := m.ToolNames()
if len(allNames) <= mcpToolInlineMaxCount {
return
}
// Build a set of names to defer (everything beyond the threshold).
deferSet := make(map[string]struct{}, len(allNames)-mcpToolInlineMaxCount)
for _, name := range allNames[mcpToolInlineMaxCount:] {
deferSet[name] = struct{}{}
}
m.mu.Lock()
defer m.mu.Unlock()
m.deferredTools = make(map[string]*BridgeTool, len(deferSet))
m.activatedTools = make(map[string]struct{})
// Move only excess tools to deferred, keep the rest inline.
for serverName := range m.servers {
var toolNames []string
if _, isPool := m.poolServers[serverName]; isPool {
toolNames = m.poolToolNames[serverName]
} else {
toolNames = m.servers[serverName].toolNames
}
var kept []string
for _, name := range toolNames {
if _, shouldDefer := deferSet[name]; !shouldDefer {
kept = append(kept, name)
continue
}
if bt, ok := m.registry.Get(name); ok {
if bridge, ok := bt.(*BridgeTool); ok {
m.deferredTools[name] = bridge
m.registry.Unregister(name)
}
}
}
// Update per-server tool names to only the kept inline tools.
if _, isPool := m.poolServers[serverName]; isPool {
m.poolToolNames[serverName] = kept
} else {
m.servers[serverName].toolNames = kept
}
}
// Update "mcp" group to only the kept inline names.
inlineNames := allNames[:mcpToolInlineMaxCount]
m.registry.RegisterToolGroup("mcp", inlineNames)
m.searchMode = true
slog.Info("mcp.search_mode.enabled",
"inline_tools", len(inlineNames),
"deferred_tools", len(m.deferredTools),
"threshold", mcpToolInlineMaxCount)
}
// IsSearchMode reports whether the manager is in deferred/search mode.
func (m *Manager) IsSearchMode() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.searchMode
}
// DeferredToolInfos returns all deferred tools for BM25 indexing.
func (m *Manager) DeferredToolInfos() []*BridgeTool {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]*BridgeTool, 0, len(m.deferredTools))
for _, bt := range m.deferredTools {
result = append(result, bt)
}
return result
}
// ActivateTools moves named deferred tools into the registry so
// they become available on the next agent loop iteration.
// Uses 3-phase locking to avoid deadlock with registry.mu.
func (m *Manager) ActivateTools(names []string) {
// Phase 1: collect tools to activate (read lock)
m.mu.RLock()
toActivate := make([]*BridgeTool, 0, len(names))
for _, name := range names {
if bt, ok := m.deferredTools[name]; ok {
if _, exists := m.registry.Get(name); !exists {
toActivate = append(toActivate, bt)
}
}
}
m.mu.RUnlock()
if len(toActivate) == 0 {
return
}
// Phase 2: register in registry (no Manager lock held)
var activated []string
for _, bt := range toActivate {
if _, exists := m.registry.Get(bt.Name()); !exists {
m.registry.Register(bt)
activated = append(activated, bt.Name())
}
}
if len(activated) == 0 {
return
}
// Phase 3: update internal state (write lock)
m.mu.Lock()
for _, name := range activated {
delete(m.deferredTools, name)
m.activatedTools[name] = struct{}{}
}
activeNames := make([]string, 0, len(m.activatedTools))
for n := range m.activatedTools {
activeNames = append(activeNames, n)
}
m.mu.Unlock()
m.registry.RegisterToolGroup("mcp", activeNames)
slog.Info("mcp.tools.activated", "tools", activated)
}
// ActivateToolIfDeferred activates a single named tool if it is currently deferred.
// Returns true if the tool is now in the registry.
// Used by the Registry's deferredActivator callback for lazy tool activation.
func (m *Manager) ActivateToolIfDeferred(name string) bool {
m.mu.Lock()
_, isDeferred := m.deferredTools[name]
_, isActivated := m.activatedTools[name]
if isActivated {
m.mu.Unlock()
return true // already activated by a concurrent call
}
if !isDeferred {
m.mu.Unlock()
return false
}
// Mark as activated under lock to prevent concurrent ActivateTools races.
m.activatedTools[name] = struct{}{}
bt := m.deferredTools[name]
delete(m.deferredTools, name)
activeNames := make([]string, 0, len(m.activatedTools))
for n := range m.activatedTools {
activeNames = append(activeNames, n)
}
m.mu.Unlock()
// Register in registry outside lock (registry has its own sync).
m.registry.Register(bt)
m.registry.RegisterToolGroup("mcp", activeNames)
slog.Info("mcp.tools.activated", "tools", []string{name})
return true
}
// Stop shuts down all MCP server connections and unregisters tools.
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
for name, ss := range m.servers {
if _, isPool := m.poolServers[name]; isPool {
// Pool-backed: unregister per-agent tools, release shared connection
for _, toolName := range m.poolToolNames[name] {
m.registry.Unregister(toolName)
}
if m.pool != nil {
if pkey, ok := m.poolKeys[name]; ok {
m.pool.Release(pkey)
}
}
} else {
// Standalone: close connection directly
if ss.cancel != nil {
ss.cancel()
}
// Use atomic pointer — health loop may swap client via fullReconnect concurrently.
if client := ss.clientPtr.Load(); client != nil {
if err := client.Close(); err != nil {
slog.Debug("mcp.server.close_error", "server", name, "error", err)
}
}
for _, toolName := range ss.toolNames {
m.registry.Unregister(toolName)
}
}
}
m.servers = make(map[string]*serverState)
m.poolServers = nil
m.poolToolNames = nil
}
// MCPToolPreviewInfo describes an MCP tool as seen from the store configuration,
// without requiring a live connection to the MCP server.
type MCPToolPreviewInfo struct {
// RegisteredName is the tool name as it appears in the tool registry (with mcp_ prefix).
RegisteredName string
// Description is derived from server tool hints, if configured.
Description string
// Parameters is the tool's cached JSON Schema for input parameters, captured
// at connect-time (see buildCachedToolInfo in manager_connect.go). nil when
// no schema has been cached yet (server never connected, or cache predates
// schema capture).
Parameters json.RawMessage
}
// mcpPreviewDiscoveryTimeout bounds the on-demand tool discovery
// ListToolsForAgent performs when a server has never been live-connected for
// this agent (empty tool_cache and empty registry). Kept short since it's on
// the hot path of a prompt-preview HTTP request, unlike the longer
// discoverToolsTimeout used by the dedicated admin "test connection"/"browse
// tools" endpoints.
const mcpPreviewDiscoveryTimeout = 5 * time.Second
// bareMCPToolName strips a persisted "{effectivePrefix}__" prefix from a
// stored tool_allow/tool_deny entry, if present.
//
// Historically, some agent grants were captured while their MCP server was
// already live-connected and ended up storing the registered (prefixed) tool
// name instead of the bare original MCP tool name — see ServerToolInfos'
// doc comment for how that mismatch happened. Those legacy rows are never
// rewritten automatically, so ListToolsForAgent must accept both shapes
// going forward: bare names (the current, correct shape) and prefixed names
// (already-persisted legacy grants), normalizing to bare before matching
// against tool_cache keys or the live tool registry.
func bareMCPToolName(stored, effectivePrefix string) string {
if bare, ok := strings.CutPrefix(stored, effectivePrefix+"__"); ok {
return bare
}
return stored
}
// resolveMCPToolInfo resolves the description and parameter schema for a bare
// MCP tool name, preferring the CURRENT live registry entry (if the server is
// connected right now) over the settings-persisted tool_cache snapshot, which
// can be stale or entirely absent. Admin-authored hints always take priority
// over both sources; the server's global hint is the last-resort fallback.
func (m *Manager) resolveMCPToolInfo(toolName, effectivePrefix string, hints ToolHints, toolCache map[string]store.CachedToolInfo) (string, json.RawMessage) {
desc := hints.HintFor(toolName)
var params json.RawMessage
if tool, ok := m.registry.Get(effectivePrefix + "__" + toolName); ok {
if bridgeTool, isBridge := tool.(*BridgeTool); isBridge {
if desc == "" {
desc = bridgeTool.Description()
}
if schema := bridgeTool.Parameters(); schema != nil {
if schemaJSON, err := json.Marshal(schema); err == nil {
params = schemaJSON
}
}
}
}
cached := toolCache[toolName]
if desc == "" {
desc = cached.Description
}
if params == nil {
params = cached.Parameters
}
if desc == "" && hints.Global != "" {
desc = hints.Global
}
return desc, params
}
// ListToolsForAgent returns a best-effort list of MCP tool names and descriptions
// for a given agent+user based on store configuration only — no actual MCP
// server connections are made. It is intended for prompt preview.
//
// For each accessible server:
// - If the agent grant has an explicit ToolAllow list, those tool names are
// used (minus any ToolDeny entries).
// - If ToolAllow is empty (all tools allowed), only a single placeholder entry
// is returned for the server (the exact tool list is unknown without connecting).
//
// Per-tool descriptions are populated from the server's tool_hints settings when present.
func (m *Manager) ListToolsForAgent(ctx context.Context, agentID uuid.UUID, userID string) ([]MCPToolPreviewInfo, error) {
slog.Debug("mcp.ListToolsForAgent.called", "agent_id", agentID, "user_id", userID)
if m.store == nil {
slog.Debug("mcp.ListToolsForAgent.no_store", "agent_id", agentID)
return nil, nil
}
accessible, err := m.store.ListAccessible(ctx, agentID, userID)
if err != nil {
return nil, fmt.Errorf("list accessible MCP servers: %w", err)
}
slog.Debug("mcp.ListToolsForAgent.accessible_servers", "agent_id", agentID, "count", len(accessible))
var result []MCPToolPreviewInfo
for _, info := range accessible {
slog.Debug("mcp.ListToolsForAgent.server", "server", info.Server.Name, "enabled", info.Server.Enabled, "tool_allow_count", len(info.ToolAllow), "tool_deny_count", len(info.ToolDeny), "has_settings", len(info.Server.Settings) > 0)
if !info.Server.Enabled {
slog.Debug("mcp.ListToolsForAgent.server_disabled", "server", info.Server.Name)
continue
}
hints := ParseToolHints(info.Server.Settings)
effectivePrefix := ensureMCPPrefix(info.Server.ToolPrefix, info.Server.Name)
slog.Debug("mcp.ListToolsForAgent.server_hints", "server", info.Server.Name, "global_hint", hints.Global, "tool_hints_count", len(hints.Tools), "effective_prefix", effectivePrefix)
// Parse tool cache from settings as fallback descriptions + parameter schemas.
toolCache := make(map[string]store.CachedToolInfo)
if len(info.Server.Settings) > 0 {
var settingsMap map[string]json.RawMessage
if err := json.Unmarshal(info.Server.Settings, &settingsMap); err == nil {
if cacheRaw, ok := settingsMap["tool_cache"]; ok {
if err := json.Unmarshal(cacheRaw, &toolCache); err != nil {
// Backward-compat: pre-schema-caching rows stored a bare
// map[string]string (name -> description). Fall back to
// that shape and treat entries as description-only (no
// parameter schema). Stale rows self-heal on next connect
// since the write path always writes the new shape.
var legacyCache map[string]string
if legacyErr := json.Unmarshal(cacheRaw, &legacyCache); legacyErr == nil {
toolCache = make(map[string]store.CachedToolInfo, len(legacyCache))
for name, desc := range legacyCache {
toolCache[name] = store.CachedToolInfo{Description: desc}
}
} else {
slog.Debug("mcp.ListToolsForAgent.tool_cache_unmarshal_failed", "server", info.Server.Name, "error", err)
}
}
}
}
}
// Nothing persisted yet and no live registry entries either (the
// server has never actually been connected for this agent, e.g. a
// freshly added MCP server before the agent's first real chat turn
// triggers Manager.LoadForAgent). ListToolsForAgent is otherwise
// documented as "no actual MCP server connections are made", which
// left the prompt preview permanently showing empty descriptions and
// "{type: object}" schemas until a real session happened to connect.
// Do a single bounded on-demand discovery here (same live path the
// admin "browse tools" endpoint already uses, see handleListServerTools
// in internal/http/mcp_tools.go) so the preview reflects real tool
// data immediately, and persist it so subsequent calls hit the cache.
if len(toolCache) == 0 && len(m.ServerToolInfos(info.Server.Name)) == 0 {
if rs := m.resolveServerCredentials(ctx, info, userID); rs != nil {
discovered, err := discoverRawTools(ctx, info.Server.Transport, info.Server.Command, rs.args, rs.env, info.Server.URL, rs.headers, mcpPreviewDiscoveryTimeout)
if err != nil {
slog.Debug("mcp.ListToolsForAgent.on_demand_discovery_failed", "server", info.Server.Name, "error", err)
} else {
toolCache = buildCachedToolInfo(discovered)
if info.Server.ID != uuid.Nil && m.store != nil && len(toolCache) > 0 {
go func(sid uuid.UUID, cache map[string]store.CachedToolInfo) {
cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if cacheErr := m.store.CacheToolDescriptions(cacheCtx, sid, cache); cacheErr != nil {
slog.Debug("mcp.ListToolsForAgent.cache_tool_descriptions_failed", "server_id", sid, "error", cacheErr)
}
}(info.Server.ID, toolCache)
}
}
}
}
// Build deny set, normalizing legacy prefixed entries to bare names
// (see bareMCPToolName) so they match tool_cache/registry lookups.
denySet := make(map[string]struct{}, len(info.ToolDeny))
for _, d := range info.ToolDeny {
denySet[bareMCPToolName(d, effectivePrefix)] = struct{}{}
}
if len(info.ToolAllow) == 0 {
if len(toolCache) > 0 {
// Unrestricted grant, but we have real tool names cached from a
// prior connection — enumerate them instead of a placeholder.
var serverTools []string
for toolName := range toolCache {
if _, denied := denySet[toolName]; denied {
slog.Debug("mcp.ListToolsForAgent.tool_denied", "server", info.Server.Name, "tool", toolName)
continue
}
registeredName := effectivePrefix + "__" + toolName
desc, params := m.resolveMCPToolInfo(toolName, effectivePrefix, hints, toolCache)
serverTools = append(serverTools, registeredName)
result = append(result, MCPToolPreviewInfo{
RegisteredName: registeredName,
Description: desc,
Parameters: params,
})
}
slog.Debug("mcp.ListToolsForAgent.server_tools_added_from_cache", "server", info.Server.Name, "tools", serverTools)
continue
}
// Unknown tool list and nothing cached — emit one placeholder entry.
placeholder := effectivePrefix + "__*"
desc := hints.Global
if desc == "" {
desc = "MCP server: " + info.Server.Name
}
slog.Debug("mcp.ListToolsForAgent.placeholder_entry", "server", info.Server.Name, "placeholder", placeholder)
result = append(result, MCPToolPreviewInfo{
RegisteredName: placeholder,
Description: desc,
})
continue
}
var serverTools []string
for _, storedName := range info.ToolAllow {
// Normalize legacy prefixed entries (persisted before the grant-capture
// fix in mcp_tools.go) to the bare tool name so they resolve against
// tool_cache and the live registry the same as current bare entries.
toolName := bareMCPToolName(storedName, effectivePrefix)
if _, denied := denySet[toolName]; denied {
slog.Debug("mcp.ListToolsForAgent.tool_denied", "server", info.Server.Name, "tool", toolName)
continue
}
registeredName := effectivePrefix + "__" + toolName
desc, params := m.resolveMCPToolInfo(toolName, effectivePrefix, hints, toolCache)
serverTools = append(serverTools, registeredName)
result = append(result, MCPToolPreviewInfo{
RegisteredName: registeredName,
Description: desc,
Parameters: params,
})
}
slog.Debug("mcp.ListToolsForAgent.server_tools_added", "server", info.Server.Name, "tools", serverTools)
}
slog.Info("mcp.ListToolsForAgent.result", "agent_id", agentID, "user_id", userID, "total_tools", len(result))
return result, nil
}
// ServerStatus returns the status of all connected MCP servers.
func (m *Manager) ServerStatus() []ServerStatus {
m.mu.RLock()
defer m.mu.RUnlock()
statuses := make([]ServerStatus, 0, len(m.servers))
for _, ss := range m.servers {
statuses = append(statuses, ServerStatus{
Name: ss.name,
Transport: ss.transport,
Connected: ss.connected.Load(),
ToolCount: len(ss.toolNames),
Error: ss.lastErr,
})
}
return statuses
}
// resolveEnvVars returns a copy of m with "env:VARNAME" values resolved to os.Getenv("VARNAME").
// Uses fail-closed validation: only allowlisted env vars are permitted.
func resolveEnvVars(m map[string]string) (map[string]string, error) {
out := make(map[string]string, len(m))
for k, v := range m {
resolved, err := ValidateAndResolveEnvVar(v)
if err != nil {
return nil, fmt.Errorf("header %q: %w", k, err)
}
out[k] = resolved
}
return out, nil
}
// requireUserCreds checks if an MCP server's settings mandate per-user credentials.
func requireUserCreds(settings json.RawMessage) bool {
if len(settings) == 0 {
return false
}
var s struct {
RequireUserCredentials bool `json:"require_user_credentials"`
}
_ = json.Unmarshal(settings, &s)
return s.RequireUserCredentials
}
// isOAuthActive checks if the server's settings have OAuth auth_type enabled.
func isOAuthActive(settings json.RawMessage) bool {
return IsOAuthActive(settings)
}
// IsOAuthActive reports whether the given raw server settings JSON has OAuth enabled.
// Exported for use by HTTP handlers that need to gate OAuth token injection.
func IsOAuthActive(settings json.RawMessage) bool {
if len(settings) == 0 {
return false
}
var s struct {
OAuth struct {
AuthType string `json:"auth_type"`
} `json:"oauth"`
}
_ = json.Unmarshal(settings, &s)
return s.OAuth.AuthType == "oauth"
}
// 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]
}