diff --git a/internal/agent/loop_mcp_user.go b/internal/agent/loop_mcp_user.go index 11a536f1..dd842c9d 100644 --- a/internal/agent/loop_mcp_user.go +++ b/internal/agent/loop_mcp_user.go @@ -15,6 +15,9 @@ import ( // connections are established via pool.AcquireUser() and BridgeTools created. func (l *Loop) getUserMCPTools(ctx context.Context, userID string) []tools.Tool { if len(l.mcpUserCredSrvs) == 0 || l.mcpPool == nil || l.mcpStore == nil || userID == "" { + if userID == "" && len(l.mcpUserCredSrvs) > 0 { + slog.Debug("mcp.user_tools_skipped", "reason", "empty_user_id", "servers", len(l.mcpUserCredSrvs)) + } return nil } @@ -100,6 +103,13 @@ func (l *Loop) getUserMCPTools(ctx context.Context, userID string) []tools.Tool if len(userTools) > 0 { l.mcpUserTools.Store(userID, userTools) + // Update "mcp" tool group so policy expansion via alsoAllow includes + // per-user tools. MergeToolGroup is additive — safe for concurrent users. + var names []string + for _, t := range userTools { + names = append(names, t.Name()) + } + tools.MergeToolGroup("mcp", names) slog.Info("mcp.user_tools_loaded", "user", userID, "tools", len(userTools)) } return userTools diff --git a/internal/agent/loop_pipeline_callbacks.go b/internal/agent/loop_pipeline_callbacks.go index 9c5f63e2..06a08130 100644 --- a/internal/agent/loop_pipeline_callbacks.go +++ b/internal/agent/loop_pipeline_callbacks.go @@ -191,6 +191,11 @@ func (l *Loop) makeInjectReminders(req *RunRequest) func(ctx context.Context, in func (l *Loop) makeBuildFilteredTools(req *RunRequest) func(state *pipeline.RunState) ([]providers.ToolDefinition, error) { return func(state *pipeline.RunState) ([]providers.ToolDefinition, error) { + // Load per-user MCP tools (Notion, etc.) into registry before filtering. + // Servers with require_user_credentials are deferred at startup and + // connected per-request here with the actual user's credentials. + l.getUserMCPTools(state.Ctx, state.Input.UserID) + maxIter := l.maxIterations if req.MaxIterations > 0 && req.MaxIterations < maxIter { maxIter = req.MaxIterations diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index f7f8ff67..7fe15b07 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -305,6 +305,12 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { slog.Warn("failed to load MCP servers for agent", "agent", agentKey, "error", err) } else { mcpUserCredSrvs = mcpMgr.UserCredServers() + // User-credential servers (Notion, etc.) are deferred at startup + // but will produce tools per-request via getUserMCPTools. + // Set flag so agentToolPolicyWithMCP injects "group:mcp" into alsoAllow. + if len(mcpUserCredSrvs) > 0 { + hasMCPTools = true + } if mcpMgr.IsSearchMode() { // Search mode: too many tools — register mcp_tool_search meta-tool. // Also wire lazy activator so deferred tools can be called by name directly. diff --git a/internal/tools/policy.go b/internal/tools/policy.go index df3cff0a..0e313959 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -50,6 +50,25 @@ func RegisterToolGroup(name string, members []string) { toolGroupsMu.Unlock() } +// MergeToolGroup adds members to an existing tool group without removing existing entries. +// Used by per-user MCP tool loading to extend the "mcp" group incrementally. +func MergeToolGroup(name string, members []string) { + toolGroupsMu.Lock() + defer toolGroupsMu.Unlock() + existing := toolGroups[name] + seen := make(map[string]bool, len(existing)) + for _, m := range existing { + seen[m] = true + } + for _, m := range members { + if !seen[m] { + existing = append(existing, m) + seen[m] = true + } + } + toolGroups[name] = existing +} + // UnregisterToolGroup removes a dynamic tool group. func UnregisterToolGroup(name string) { toolGroupsMu.Lock()