mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-05 06:13:51 +00:00
* feat(mcp): MCP OAuth 2.1 client — full implementation with tests
Implements a complete MCP OAuth 2.1 authorization flow for tool servers that
require user-delegated access, covering all layers from DB to UI.
- discovery.go: RFC 9728 protected-resource → RFC 8414 AS metadata → OIDC
fallback chain with 5-min in-memory cache and InvalidateCache()
- dcr.go: RFC 7591 Dynamic Client Registration with response size guard
- flow.go: PKCE (S256) authorization code flow — StartFlow(), ExchangeCode(),
ClientCredentials(), auto-cleanup of expired flows; carries AS issuer through
PendingFlow for status display
- refresher.go: OAuthTokenProvider with in-memory token cache, automatic refresh
on expiry, per-user vs global slot isolation, InvalidateCache/InvalidateServer
- migrations/000074 + SQLite schema: mcp_oauth_tokens with AES-256-GCM encrypted
access/refresh tokens, partial unique index for global vs per-user rows,
ON DELETE CASCADE from mcp_servers
- store.MCPOAuthTokenStore: Upsert, Get/GetUser, Delete/DeleteUser, and
DeleteServerOAuthTokens (purge all rows for a server)
- PostgreSQL + SQLite implementations
- POST /v1/mcp/oauth/start — discovery + optional DCR + PKCE redirect URL;
client_credentials completes server-side (no redirect) and returns completed=true
- GET /v1/mcp/oauth/callback — exchange code, persist token, publish WS event;
payload built via json.Marshal (no reflected XSS via error_description)
- GET /v1/mcp/oauth/status/{id}, DELETE /v1/mcp/oauth/token/{id} — admin-gated
- POST /v1/mcp/oauth/discover/{id} — on-demand discovery probe
- All outbound calls go through the SSRF-safe client with pinned IPs
- pkg/protocol/mcp_events.go: EventMCPOAuthComplete routed only to the initiating
user (admins in-tenant included); fail-closed across tenants
- getUserMCPTools() injects Authorization: Bearer from OAuthTokenProvider; on a
401 for OAuth servers it purges the cached token so the next turn re-resolves
- handleUpdateServer purges all OAuth tokens (global + per-user), drops the
refresher cache, and evicts the pool when a server's URL or OAuth config
(client_id / endpoints / grant_type / scope / auth_type) changes — so the
status UI and agent never use a token minted for the old resource/AS
- MCPOAuthDialog (WS-driven), unified user-credentials dialog, OAuth settings
fields; handles the no-redirect client_credentials completion
- internal/mcp/oauth/*_test.go: discovery cache, PKCE, DCR, refresher
- internal/http/mcp_oauth_test.go + mcp_update_oauth_purge_test.go: routes, auth
gating, WS event, purge-on-URL/OAuth-config-change
- tests/integration: store + encryption + tenant isolation, E2E start→callback,
DeleteServerOAuthTokens
- internal/gateway/event_filter_test.go, internal/agent/loop_mcp_user_test.go
* fix(mcp): return 400 on OAuth callback with code but missing state
The callback handler rendered a 200 HTML page whenever code or state was
absent. An auth code WITH a missing state is a malformed / CSRF-risk
callback (state is the CSRF token), so reject that case with HTTP 400.
A bare hit with neither code nor state (user opening the URL directly),
provider errors, and exchange failures keep their 200 HTML popup page.
Adds a status code parameter to writeCallbackHTML. Fixes the
TestOAuthCallbackMissingState integration regression while keeping
TestHandleCallbackMissingCodeAndState (no params -> 200) green.
* fix(mcp): scope-based OAuth auth + honor manual OAuth endpoints
Addresses the two MCP/OAuth security-review findings.
Finding 1 — authorization. mcp_oauth_tokens is tenant-scoped, but
start/status/revoke were gated only by requireAuth(RoleAdmin), an RBAC
role check, not tenant membership, so a RoleAdmin caller could act on a
tenant they don't administer. A blanket requireTenantAdmin would have
broken per-user self-service, which the UI exposes (the per-user
MCPUserCredentialsDialog shows an "Authorize" button to regular users for
their own credentials). Instead mirror the existing per-user MCP
credentials model (resolveTargetUserID in mcp_user_credentials.go):
- start/status/revoke accept any authenticated user; each handler calls
authorizeOAuthScope.
- a caller may manage their OWN per-user token (self-service); the
global/server token (user_id="") and other users' tokens require
tenant-admin (owner bypass), so a RoleAdmin that is not a tenant admin
is rejected.
- discover stays admin-only (it only previews AS metadata for a server).
Add a TenantStore dependency. Tests cover self-service, on-behalf-of-
another (403), and global-by-non-tenant-admin (403).
Finding 2 — honor manual OAuth config end-to-end. The UI sent use_dcr /
auth_endpoint / token_endpoint and the update path fingerprinted them for
purge, but handleStart always discovered + DCR'd and ignored them. Now:
- use_dcr=false (a *bool, so legacy/absent stays discover+DCR) skips
discovery/registration and uses the operator endpoints, SSRF-validated.
- token_endpoint is always required; auth_endpoint only for auth-code
grants — client_credentials needs no authorization URL, matching the UI
which hides that field for that grant.
- the refresher already refreshes against the stored token_endpoint and
the callback persists it, so manual-mode tokens refresh correctly.
- oauthFingerprint includes use_dcr (nil normalized to true) so toggling
DCR mode purges stale tokens.
- the web form only serializes manual endpoints when use_dcr is off.
Audited all MCP dialogs (form, global OAuth, per-user credentials, grants,
tools): OAuth dialogs handle completed/auth_url identically and read
config from stored server settings; runtime connect uses the stored token
via the refresher (no re-discovery).
Tests: manual auth-code + client_credentials endpoints, missing/SSRF
endpoints, and the full self/global/on-behalf authorization matrix.
736 lines
20 KiB
Go
736 lines
20 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
mcpclient "github.com/mark3labs/mcp-go/client"
|
|
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
// PoolConfig configures the MCP connection pool.
|
|
type PoolConfig struct {
|
|
MaxSize int // global max connections (default 200)
|
|
MaxIdle int // max idle connections to keep alive (default 20)
|
|
IdleTTL time.Duration // close idle connections after this (default 20m)
|
|
AcquireTimeout time.Duration // wait for pool slot before error (default 60s)
|
|
MaxUserConns int // max per-user connections per MCP server (default 30)
|
|
UserIdleTTL time.Duration // close idle user connections after this (default 15m)
|
|
UserAcquireTimeout time.Duration // wait for user pool slot before error (default 10s)
|
|
}
|
|
|
|
// DefaultPoolConfig returns the default pool configuration.
|
|
func DefaultPoolConfig() PoolConfig {
|
|
return PoolConfig{
|
|
MaxSize: 200,
|
|
MaxIdle: 20,
|
|
IdleTTL: 20 * time.Minute,
|
|
AcquireTimeout: 60 * time.Second,
|
|
MaxUserConns: 30,
|
|
UserIdleTTL: 15 * time.Minute,
|
|
UserAcquireTimeout: 10 * time.Second,
|
|
}
|
|
}
|
|
|
|
// poolEntry holds a shared connection and its discovered tools.
|
|
type poolEntry struct {
|
|
state *serverState // connection + health state
|
|
tools []mcpgo.Tool // discovered MCP tool definitions
|
|
refCount int // number of active Manager references
|
|
lastUsed time.Time // last Acquire/Release time for idle eviction
|
|
}
|
|
|
|
// Pool manages shared MCP server connections across agents.
|
|
// Connections are keyed by tenantID/serverName for tenant isolation.
|
|
// Per-user connections are keyed by tenantID/serverName/user:userID.
|
|
type Pool struct {
|
|
mu sync.Mutex
|
|
servers map[string]*poolEntry // shared connections: tenantID/serverName
|
|
userServers map[string]*poolEntry // user connections: tenantID/serverName/user:userID
|
|
userSlots map[string]chan struct{} // per-server semaphores: tenantID/serverName → capacity MaxUserConns
|
|
cfg PoolConfig
|
|
slot chan struct{} // semaphore for MaxSize
|
|
stopCh chan struct{}
|
|
}
|
|
|
|
// NewPool creates a shared MCP connection pool with idle eviction.
|
|
func NewPool(cfg PoolConfig) *Pool {
|
|
if cfg.MaxSize <= 0 {
|
|
cfg.MaxSize = 200
|
|
}
|
|
if cfg.MaxIdle <= 0 {
|
|
cfg.MaxIdle = 20
|
|
}
|
|
if cfg.IdleTTL <= 0 {
|
|
cfg.IdleTTL = 20 * time.Minute
|
|
}
|
|
if cfg.AcquireTimeout <= 0 {
|
|
cfg.AcquireTimeout = 60 * time.Second
|
|
}
|
|
if cfg.MaxUserConns <= 0 {
|
|
cfg.MaxUserConns = 30
|
|
}
|
|
if cfg.UserIdleTTL <= 0 {
|
|
cfg.UserIdleTTL = 15 * time.Minute
|
|
}
|
|
if cfg.UserAcquireTimeout <= 0 {
|
|
cfg.UserAcquireTimeout = 10 * time.Second
|
|
}
|
|
|
|
p := &Pool{
|
|
servers: make(map[string]*poolEntry),
|
|
userServers: make(map[string]*poolEntry),
|
|
userSlots: make(map[string]chan struct{}),
|
|
cfg: cfg,
|
|
slot: make(chan struct{}, cfg.MaxSize),
|
|
stopCh: make(chan struct{}),
|
|
}
|
|
go p.evictLoop()
|
|
return p
|
|
}
|
|
|
|
// poolKey builds a tenant-scoped key for pool lookups.
|
|
func poolKey(tenantID uuid.UUID, name string) string {
|
|
return tenantID.String() + "/" + name
|
|
}
|
|
|
|
// UserPoolKey builds a tenant+user-scoped key for user pool lookups.
|
|
// Exported for callers that need to construct release keys.
|
|
func UserPoolKey(tenantID uuid.UUID, serverName, userID string) string {
|
|
return tenantID.String() + "/" + serverName + "/user:" + userID
|
|
}
|
|
|
|
// userSlotKey returns the per-server semaphore key (tenantID/serverName).
|
|
func userSlotKey(tenantID uuid.UUID, serverName string) string {
|
|
return tenantID.String() + "/" + serverName
|
|
}
|
|
|
|
// Acquire returns a shared connection for the named server scoped to a tenant.
|
|
// If no connection exists, it connects using the provided config.
|
|
// Blocks up to AcquireTimeout if pool is at MaxSize.
|
|
func (p *Pool) Acquire(ctx context.Context, tenantID uuid.UUID, name, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) {
|
|
key := poolKey(tenantID, name)
|
|
|
|
p.mu.Lock()
|
|
if entry, ok := p.servers[key]; ok && entry.state.connected.Load() {
|
|
entry.refCount++
|
|
entry.lastUsed = time.Now()
|
|
p.mu.Unlock()
|
|
slog.Debug("mcp.pool.reuse", "key", key, "refCount", entry.refCount)
|
|
return entry, nil
|
|
}
|
|
|
|
// If entry exists but disconnected, close old and reclaim slot
|
|
if old, ok := p.servers[key]; ok {
|
|
if old.state.cancel != nil {
|
|
old.state.cancel()
|
|
}
|
|
if client := old.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.servers, key)
|
|
// Return slot to semaphore
|
|
select {
|
|
case <-p.slot:
|
|
default:
|
|
}
|
|
}
|
|
p.mu.Unlock()
|
|
|
|
// Acquire a slot (blocks if pool full, evicts idle if possible)
|
|
if err := p.acquireSlot(ctx); err != nil {
|
|
return nil, fmt.Errorf("mcp pool exhausted: %w", err)
|
|
}
|
|
|
|
// Connect outside the lock (may be slow)
|
|
ss, mcpTools, err := connectAndDiscover(ctx, name, transportType, command, args, env, url, headers, timeoutSec)
|
|
if err != nil {
|
|
// Return slot on failure
|
|
select {
|
|
case <-p.slot:
|
|
default:
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Start health loop
|
|
hctx, hcancel := context.WithCancel(context.Background())
|
|
ss.cancel = hcancel
|
|
go poolHealthLoop(hctx, ss)
|
|
|
|
entry := &poolEntry{
|
|
state: ss,
|
|
tools: mcpTools,
|
|
refCount: 1,
|
|
lastUsed: time.Now(),
|
|
}
|
|
|
|
p.mu.Lock()
|
|
// Check if another goroutine connected while we were connecting
|
|
if existing, ok := p.servers[key]; ok && existing.state.connected.Load() {
|
|
p.mu.Unlock()
|
|
hcancel()
|
|
_ = ss.client.Close()
|
|
// Return our extra slot
|
|
select {
|
|
case <-p.slot:
|
|
default:
|
|
}
|
|
p.mu.Lock()
|
|
existing.refCount++
|
|
existing.lastUsed = time.Now()
|
|
p.mu.Unlock()
|
|
return existing, nil
|
|
}
|
|
p.servers[key] = entry
|
|
p.mu.Unlock()
|
|
|
|
slog.Info("mcp.pool.connected", "key", key, "tools", len(mcpTools))
|
|
return entry, nil
|
|
}
|
|
|
|
// AcquireUser returns a per-user connection for the named server scoped to a tenant+user.
|
|
// If no connection exists, it connects using the provided config.
|
|
// Blocks up to UserAcquireTimeout if per-server user slot limit is reached.
|
|
func (p *Pool) AcquireUser(ctx context.Context, tenantID uuid.UUID, name, userID, transportType, command string, args []string, env map[string]string, url string, headers map[string]string, timeoutSec int) (*poolEntry, error) {
|
|
key := UserPoolKey(tenantID, name, userID)
|
|
slotKey := userSlotKey(tenantID, name)
|
|
|
|
p.mu.Lock()
|
|
if entry, ok := p.userServers[key]; ok && entry.state.connected.Load() {
|
|
entry.refCount++
|
|
entry.lastUsed = time.Now()
|
|
p.mu.Unlock()
|
|
slog.Debug("mcp.pool.user.reuse", "key", key, "refCount", entry.refCount)
|
|
return entry, nil
|
|
}
|
|
|
|
// If entry exists but disconnected, close old and reclaim slot
|
|
if old, ok := p.userServers[key]; ok {
|
|
if old.state.cancel != nil {
|
|
old.state.cancel()
|
|
}
|
|
if client := old.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.userServers, key)
|
|
// Return slot to per-server semaphore
|
|
if sem, ok := p.userSlots[slotKey]; ok {
|
|
select {
|
|
case <-sem:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure per-server semaphore exists (lazy init under lock)
|
|
if _, ok := p.userSlots[slotKey]; !ok {
|
|
p.userSlots[slotKey] = make(chan struct{}, p.cfg.MaxUserConns)
|
|
}
|
|
sem := p.userSlots[slotKey]
|
|
p.mu.Unlock()
|
|
|
|
// Acquire a user slot for this server (blocks up to UserAcquireTimeout)
|
|
if err := p.acquireUserSlot(ctx, sem, slotKey); err != nil {
|
|
return nil, fmt.Errorf("mcp user pool exhausted for server %s: %w", name, err)
|
|
}
|
|
|
|
// Connect outside the lock (may be slow)
|
|
ss, mcpTools, err := connectAndDiscover(ctx, name, transportType, command, args, env, url, headers, timeoutSec)
|
|
if err != nil {
|
|
// Return slot on failure
|
|
select {
|
|
case <-sem:
|
|
default:
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Start health loop
|
|
hctx, hcancel := context.WithCancel(context.Background())
|
|
ss.cancel = hcancel
|
|
go poolHealthLoop(hctx, ss)
|
|
|
|
entry := &poolEntry{
|
|
state: ss,
|
|
tools: mcpTools,
|
|
refCount: 1,
|
|
lastUsed: time.Now(),
|
|
}
|
|
|
|
p.mu.Lock()
|
|
// Check if another goroutine connected while we were connecting
|
|
if existing, ok := p.userServers[key]; ok && existing.state.connected.Load() {
|
|
p.mu.Unlock()
|
|
hcancel()
|
|
_ = ss.client.Close()
|
|
// Return our extra slot
|
|
select {
|
|
case <-sem:
|
|
default:
|
|
}
|
|
p.mu.Lock()
|
|
existing.refCount++
|
|
existing.lastUsed = time.Now()
|
|
p.mu.Unlock()
|
|
return existing, nil
|
|
}
|
|
p.userServers[key] = entry
|
|
p.mu.Unlock()
|
|
|
|
slog.Info("mcp.pool.user.connected", "key", key, "tools", len(mcpTools))
|
|
return entry, nil
|
|
}
|
|
|
|
// acquireSlot tries to acquire a pool slot, evicting idle connections if needed.
|
|
func (p *Pool) acquireSlot(ctx context.Context) error {
|
|
// Fast path: slot available
|
|
select {
|
|
case p.slot <- struct{}{}:
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
// Try evicting one idle entry
|
|
p.mu.Lock()
|
|
evicted := p.evictOldestIdleLocked()
|
|
p.mu.Unlock()
|
|
|
|
if evicted {
|
|
select {
|
|
case p.slot <- struct{}{}:
|
|
return nil
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Wait up to AcquireTimeout
|
|
timer := time.NewTimer(p.cfg.AcquireTimeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case p.slot <- struct{}{}:
|
|
return nil
|
|
case <-timer.C:
|
|
return fmt.Errorf("timeout after %s waiting for pool slot (max %d)", p.cfg.AcquireTimeout, p.cfg.MaxSize)
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// acquireUserSlot tries to acquire a per-server user slot.
|
|
func (p *Pool) acquireUserSlot(ctx context.Context, sem chan struct{}, slotKey string) error {
|
|
// Fast path: slot available
|
|
select {
|
|
case sem <- struct{}{}:
|
|
return nil
|
|
default:
|
|
}
|
|
|
|
// Wait up to UserAcquireTimeout
|
|
timer := time.NewTimer(p.cfg.UserAcquireTimeout)
|
|
defer timer.Stop()
|
|
|
|
select {
|
|
case sem <- struct{}{}:
|
|
return nil
|
|
case <-timer.C:
|
|
return fmt.Errorf("timeout after %s waiting for user slot (max %d, server %s)", p.cfg.UserAcquireTimeout, p.cfg.MaxUserConns, slotKey)
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
}
|
|
}
|
|
|
|
// Release decrements the reference count for a server.
|
|
// Accepts the same key format as Acquire (tenantID + name).
|
|
func (p *Pool) Release(key string) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if entry, ok := p.servers[key]; ok {
|
|
entry.refCount--
|
|
if entry.refCount < 0 {
|
|
entry.refCount = 0
|
|
}
|
|
entry.lastUsed = time.Now()
|
|
slog.Debug("mcp.pool.release", "key", key, "refCount", entry.refCount)
|
|
}
|
|
}
|
|
|
|
// ReleaseUser decrements the reference count for a user-scoped connection.
|
|
// Accepts the same key format as AcquireUser (tenantID + serverName + userID).
|
|
func (p *Pool) ReleaseUser(key string) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
if entry, ok := p.userServers[key]; ok {
|
|
entry.refCount--
|
|
if entry.refCount < 0 {
|
|
entry.refCount = 0
|
|
}
|
|
entry.lastUsed = time.Now()
|
|
slog.Debug("mcp.pool.user.release", "key", key, "refCount", entry.refCount)
|
|
}
|
|
}
|
|
|
|
// Stop closes all pooled connections and stops eviction. Called on gateway shutdown.
|
|
func (p *Pool) Stop() {
|
|
close(p.stopCh)
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
for key, entry := range p.servers {
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
slog.Debug("mcp.pool.stopped", "key", key)
|
|
}
|
|
p.servers = make(map[string]*poolEntry)
|
|
|
|
for key, entry := range p.userServers {
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
slog.Debug("mcp.pool.user.stopped", "key", key)
|
|
}
|
|
p.userServers = make(map[string]*poolEntry)
|
|
}
|
|
|
|
// Evict closes a specific pooled connection by tenant + server name.
|
|
// Called when server credentials are rotated to force reconnection with new credentials.
|
|
func (p *Pool) Evict(tenantID uuid.UUID, serverName string) {
|
|
key := poolKey(tenantID, serverName)
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
entry, ok := p.servers[key]
|
|
if !ok {
|
|
return
|
|
}
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.servers, key)
|
|
select {
|
|
case <-p.slot:
|
|
default:
|
|
}
|
|
slog.Info("mcp.pool.evicted_on_rotation", "key", key)
|
|
}
|
|
|
|
// EvictAllUsers closes and removes all per-user pool connections.
|
|
// Called when MCP grants or server settings change so next AcquireUser picks
|
|
// up fresh credentials/headers. Shared (non-user) connections are unaffected.
|
|
func (p *Pool) EvictAllUsers() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
for key, entry := range p.userServers {
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.userServers, key)
|
|
}
|
|
// Reset per-server semaphores so slot counts don't leak.
|
|
p.userSlots = make(map[string]chan struct{})
|
|
slog.Info("mcp.pool.all_user_connections_evicted")
|
|
}
|
|
|
|
// EvictServer evicts both the shared pool entry and all per-user pool entries
|
|
// for a given server. Call this when server settings or grants change so that
|
|
// the next AcquireUser/Acquire picks up fresh credentials and configuration.
|
|
func (p *Pool) EvictServer(tenantID uuid.UUID, serverName string) {
|
|
sharedKey := poolKey(tenantID, serverName)
|
|
prefix := tenantID.String() + "/" + serverName + "/user:"
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Evict shared connection.
|
|
if entry, ok := p.servers[sharedKey]; ok {
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.servers, sharedKey)
|
|
select {
|
|
case <-p.slot:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Evict all per-user connections for this server.
|
|
slotKey := userSlotKey(tenantID, serverName)
|
|
sem := p.userSlots[slotKey]
|
|
for key, entry := range p.userServers {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.userServers, key)
|
|
if sem != nil {
|
|
select {
|
|
case <-sem:
|
|
default:
|
|
}
|
|
}
|
|
}
|
|
slog.Info("mcp.pool.server_evicted", "tenant", tenantID, "server", serverName)
|
|
}
|
|
|
|
// evictLoop runs periodically to close idle connections over MaxIdle count.
|
|
func (p *Pool) evictLoop() {
|
|
ticker := time.NewTicker(60 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-p.stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
p.evictIdle()
|
|
}
|
|
}
|
|
}
|
|
|
|
// evictIdle closes connections idle > IdleTTL when total idle exceeds MaxIdle.
|
|
// Also evicts user connections idle > UserIdleTTL.
|
|
func (p *Pool) evictIdle() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
|
|
// Evict shared connections
|
|
var idleKeys []string
|
|
for key, entry := range p.servers {
|
|
if entry.refCount == 0 && now.Sub(entry.lastUsed) > p.cfg.IdleTTL {
|
|
idleKeys = append(idleKeys, key)
|
|
}
|
|
}
|
|
|
|
// Count total idle (refCount == 0)
|
|
totalIdle := 0
|
|
for _, entry := range p.servers {
|
|
if entry.refCount == 0 {
|
|
totalIdle++
|
|
}
|
|
}
|
|
|
|
// Only evict if over MaxIdle
|
|
toEvict := totalIdle - p.cfg.MaxIdle
|
|
if toEvict > 0 || len(idleKeys) > 0 {
|
|
for _, key := range idleKeys {
|
|
entry := p.servers[key]
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.servers, key)
|
|
select {
|
|
case <-p.slot:
|
|
default:
|
|
}
|
|
slog.Debug("mcp.pool.evicted", "key", key, "reason", "idle_ttl")
|
|
}
|
|
}
|
|
|
|
// Evict user connections idle > UserIdleTTL
|
|
for key, entry := range p.userServers {
|
|
if entry.refCount == 0 && now.Sub(entry.lastUsed) > p.cfg.UserIdleTTL {
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if client := entry.state.clientPtr.Load(); client != nil {
|
|
_ = client.Close()
|
|
}
|
|
delete(p.userServers, key)
|
|
// Return slot to per-server semaphore
|
|
// Extract slotKey from user key: "tenantID/serverName/user:userID" → "tenantID/serverName"
|
|
// We search userSlots by iterating — key format guarantees prefix match
|
|
for slotKey, sem := range p.userSlots {
|
|
if strings.HasPrefix(key, slotKey+"/") {
|
|
select {
|
|
case <-sem:
|
|
default:
|
|
}
|
|
break
|
|
}
|
|
}
|
|
slog.Debug("mcp.pool.user.evicted", "key", key, "reason", "idle_ttl")
|
|
}
|
|
}
|
|
}
|
|
|
|
// evictOldestIdleLocked evicts one idle entry (oldest lastUsed) from shared or user pools.
|
|
// Caller must hold mu.
|
|
func (p *Pool) evictOldestIdleLocked() bool {
|
|
var oldestKey string
|
|
var oldestTime time.Time
|
|
isUser := false
|
|
|
|
for key, entry := range p.servers {
|
|
if entry.refCount == 0 {
|
|
if oldestKey == "" || entry.lastUsed.Before(oldestTime) {
|
|
oldestKey = key
|
|
oldestTime = entry.lastUsed
|
|
isUser = false
|
|
}
|
|
}
|
|
}
|
|
|
|
for key, entry := range p.userServers {
|
|
if entry.refCount == 0 {
|
|
if oldestKey == "" || entry.lastUsed.Before(oldestTime) {
|
|
oldestKey = key
|
|
oldestTime = entry.lastUsed
|
|
isUser = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if oldestKey == "" {
|
|
return false
|
|
}
|
|
|
|
if isUser {
|
|
entry := p.userServers[oldestKey]
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if entry.state.client != nil {
|
|
_ = entry.state.client.Close()
|
|
}
|
|
delete(p.userServers, oldestKey)
|
|
for slotKey, sem := range p.userSlots {
|
|
if strings.HasPrefix(oldestKey, slotKey+"/") {
|
|
select {
|
|
case <-sem:
|
|
default:
|
|
}
|
|
break
|
|
}
|
|
}
|
|
} else {
|
|
entry := p.servers[oldestKey]
|
|
if entry.state.cancel != nil {
|
|
entry.state.cancel()
|
|
}
|
|
if entry.state.client != nil {
|
|
_ = entry.state.client.Close()
|
|
}
|
|
delete(p.servers, oldestKey)
|
|
select {
|
|
case <-p.slot:
|
|
default:
|
|
}
|
|
}
|
|
|
|
slog.Debug("mcp.pool.evicted", "key", oldestKey, "reason", "make_room", "user", isUser)
|
|
return true
|
|
}
|
|
|
|
// ClientPtr returns the atomic client pointer for this pool entry.
|
|
// Used by BridgeTools to atomically load the current client during reconnect.
|
|
func (e *poolEntry) ClientPtr() *atomic.Pointer[mcpclient.Client] { return &e.state.clientPtr }
|
|
|
|
// Connected returns a pointer to the connected flag for this pool entry.
|
|
func (e *poolEntry) Connected() *atomic.Bool { return &e.state.connected }
|
|
|
|
// MCPTools returns the discovered MCP tool definitions for this pool entry.
|
|
func (e *poolEntry) MCPTools() []mcpgo.Tool { return e.tools }
|
|
|
|
// RequestForceReconnect triggers an out-of-band Initialize when a BridgeTool
|
|
// detects the server-side session was reset (see isSessionUninitializedErr).
|
|
// Returns a closure to keep BridgeTool decoupled from *serverState.
|
|
// Concurrent invocations dedupe via the underlying CAS guard.
|
|
func (e *poolEntry) RequestForceReconnect() func(reason string) {
|
|
ss := e.state
|
|
return func(reason string) { ss.requestForceReconnect(reason) }
|
|
}
|
|
|
|
// poolHealthLoop is a standalone health loop for pool-managed connections.
|
|
// After consecutive ping failures, it attempts a full reconnect by creating
|
|
// a fresh client, mirroring the Manager.tryReconnect slow path.
|
|
func poolHealthLoop(ctx context.Context, ss *serverState) {
|
|
ticker := newHealthTicker()
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
// Skip ping while a force-reconnect is in flight (see
|
|
// requestForceReconnect). Pinging here races the recovery
|
|
// goroutine and would clobber connected=true on servers that
|
|
// answer `ping` even in the post-reset "initializing" state.
|
|
if ss.reconnPending.Load() {
|
|
slog.Debug("mcp.pool.health_skip", "server", ss.name, "reason", "reconnect_pending")
|
|
continue
|
|
}
|
|
if err := ss.client.Ping(ctx); err != nil {
|
|
if isMethodNotFound(err) {
|
|
ss.connected.Store(true)
|
|
ss.mu.Lock()
|
|
ss.healthFailures = 0
|
|
ss.mu.Unlock()
|
|
continue
|
|
}
|
|
ss.mu.Lock()
|
|
ss.healthFailures++
|
|
failures := ss.healthFailures
|
|
ss.lastErr = err.Error()
|
|
ss.mu.Unlock()
|
|
|
|
slog.Warn("mcp.pool.health_failed", "server", ss.name, "error", err, "consecutive", failures)
|
|
|
|
if failures >= healthFailThreshold {
|
|
ss.connected.Store(false)
|
|
poolTryReconnect(ctx, ss)
|
|
}
|
|
} else {
|
|
ss.connected.Store(true)
|
|
ss.mu.Lock()
|
|
ss.reconnAttempts = 0
|
|
ss.healthFailures = 0
|
|
ss.lastErr = ""
|
|
ss.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// poolTryReconnect attempts reconnect for a pool-managed connection.
|
|
// Delegates to the shared reconnectWithBackoff with pool-specific log prefix.
|
|
func poolTryReconnect(ctx context.Context, ss *serverState) {
|
|
reconnectWithBackoff(ctx, ss, "mcp.pool")
|
|
}
|