-
Notifications
You must be signed in to change notification settings - Fork 191
Add DoS protection and idle timeout to vMCP session manager #4047
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
yrobla
wants to merge
1
commit into
main
Choose a base branch
from
issue-3874
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,6 +16,7 @@ import ( | |
| "log/slog" | ||
| "net" | ||
| "net/http" | ||
| "strconv" | ||
| "strings" | ||
| "sync" | ||
| "time" | ||
|
|
@@ -67,6 +68,13 @@ const ( | |
| // defaultSessionTTL is the default session time-to-live duration. | ||
| // Sessions that are inactive for this duration will be automatically cleaned up. | ||
| defaultSessionTTL = 30 * time.Minute | ||
|
|
||
| // defaultIdleCheckInterval is how often the idle reaper scans for inactive sessions. | ||
| defaultIdleCheckInterval = time.Minute | ||
|
|
||
| // defaultRetryAfterSeconds is the Retry-After value returned with HTTP 503 | ||
| // when the global session limit is reached. | ||
| defaultRetryAfterSeconds = 30 | ||
| ) | ||
|
|
||
| //go:generate mockgen -destination=mocks/mock_watcher.go -package=mocks -source=server.go Watcher | ||
|
|
@@ -160,6 +168,21 @@ type Config struct { | |
| // SessionFactory creates MultiSessions for Phase 2 session management. | ||
| // Required when SessionManagementV2 is true; ignored otherwise. | ||
| SessionFactory vmcpsession.MultiSessionFactory | ||
|
|
||
| // MaxSessions is the global concurrent session limit when SessionManagementV2 is enabled. | ||
| // Requests that would exceed this limit receive HTTP 503 with a Retry-After header. | ||
| // 0 uses the default (100). Requires SessionManagementV2 = true. | ||
| MaxSessions int | ||
|
|
||
| // MaxSessionsPerClient is the per-identity session limit when SessionManagementV2 is enabled. | ||
| // Keyed by auth.Identity.Subject; anonymous clients are not limited. | ||
| // 0 uses the default (10). Requires SessionManagementV2 = true. | ||
| MaxSessionsPerClient int | ||
|
|
||
| // IdleSessionTimeout is the duration after which inactive sessions are proactively | ||
| // expired when SessionManagementV2 is enabled. Must be ≤ SessionTTL. | ||
| // 0 uses the default (5 minutes). Requires SessionManagementV2 = true. | ||
| IdleSessionTimeout time.Duration | ||
| } | ||
|
|
||
| // Server is the Virtual MCP Server that aggregates multiple backends. | ||
|
|
@@ -277,6 +300,24 @@ func New( | |
| if cfg.SessionTTL == 0 { | ||
| cfg.SessionTTL = defaultSessionTTL | ||
| } | ||
| if cfg.MaxSessions == 0 { | ||
| cfg.MaxSessions = sessionmanager.DefaultMaxSessions | ||
| } | ||
| if cfg.MaxSessionsPerClient == 0 { | ||
| cfg.MaxSessionsPerClient = sessionmanager.DefaultMaxSessionsPerClient | ||
| } | ||
| if cfg.IdleSessionTimeout == 0 { | ||
| cfg.IdleSessionTimeout = sessionmanager.DefaultIdleSessionTimeout | ||
| } | ||
| // IdleSessionTimeout must not exceed SessionTTL: if it did, the transport | ||
| // TTL reaper could evict sessions before the idle reaper fires, leaving | ||
| // per-client counters and idle-tracking maps stale. | ||
| if cfg.IdleSessionTimeout > cfg.SessionTTL { | ||
| slog.Warn("IdleSessionTimeout exceeds SessionTTL; clamping to SessionTTL", | ||
| "idle_session_timeout", cfg.IdleSessionTimeout, | ||
| "session_ttl", cfg.SessionTTL) | ||
| cfg.IdleSessionTimeout = cfg.SessionTTL | ||
| } | ||
|
|
||
| // Create hooks for SDK integration | ||
| hooks := &server.Hooks{} | ||
|
|
@@ -400,7 +441,12 @@ func New( | |
| if cfg.SessionFactory == nil { | ||
| return nil, fmt.Errorf("SessionManagementV2 is enabled but no SessionFactory was provided") | ||
| } | ||
| vmcpSessMgr = sessionmanager.New(sessionManager, cfg.SessionFactory, backendRegistry) | ||
| limits := sessionmanager.Limits{ | ||
| MaxSessions: cfg.MaxSessions, | ||
| MaxSessionsPerClient: cfg.MaxSessionsPerClient, | ||
| IdleSessionTimeout: cfg.IdleSessionTimeout, | ||
| } | ||
| vmcpSessMgr = sessionmanager.New(sessionManager, cfg.SessionFactory, backendRegistry, limits) | ||
| slog.Info("session-scoped backend lifecycle enabled") | ||
|
|
||
| // Warn about incompatible optimizer configuration and disable it | ||
|
|
@@ -557,6 +603,13 @@ func (s *Server) Handler(_ context.Context) (http.Handler, error) { | |
| slog.Info("audit middleware enabled for MCP endpoints") | ||
| } | ||
|
|
||
| // Apply session limit middleware when V2 session management is active. | ||
| // Runs before auth so over-limit requests are rejected early without auth overhead. | ||
| if s.vmcpSessionMgr != nil && s.config.MaxSessions > 0 { | ||
| mcpHandler = s.sessionLimitMiddleware(mcpHandler) | ||
| slog.Info("session limit middleware enabled", "max_sessions", s.config.MaxSessions) | ||
| } | ||
|
|
||
| // Apply authentication middleware if configured (runs first in chain) | ||
| if s.config.AuthMiddleware != nil { | ||
| mcpHandler = s.config.AuthMiddleware(mcpHandler) | ||
|
|
@@ -575,6 +628,37 @@ func (s *Server) Handler(_ context.Context) (http.Handler, error) { | |
| return mux, nil | ||
| } | ||
|
|
||
| // sessionLimitMiddleware is a best-effort fast-fail for new session requests | ||
| // (no Mcp-Session-Id header): it returns HTTP 503 + Retry-After before the | ||
| // request reaches the SDK when the global session cap appears to be reached. | ||
| // Existing sessions (with a valid Mcp-Session-Id) are never affected. | ||
| // | ||
| // This check is intentionally optimistic (non-atomic): it avoids the overhead | ||
| // of routing and SDK processing for clearly-over-limit requests, but it does | ||
| // not guarantee strict enforcement under concurrent load. Strict enforcement | ||
| // is provided atomically by sessionmanager.Manager.Generate(), which uses an | ||
| // increment-first reservation to prevent races between concurrent initialize | ||
| // requests. | ||
| func (s *Server) sessionLimitMiddleware(next http.Handler) http.Handler { | ||
| // Resolve the concrete manager once so we can call ActiveSessionCount(). | ||
| mgr, _ := s.vmcpSessionMgr.(*sessionmanager.Manager) | ||
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| if r.Header.Get("Mcp-Session-Id") == "" && mgr != nil { | ||
| if mgr.ActiveSessionCount() >= s.config.MaxSessions { | ||
| w.Header().Set("Retry-After", strconv.Itoa(defaultRetryAfterSeconds)) | ||
| w.Header().Set("Content-Type", "application/json") | ||
| w.WriteHeader(http.StatusServiceUnavailable) | ||
| _, _ = w.Write([]byte( | ||
| `{"error":{"code":-32000,"message":"Maximum concurrent sessions exceeded. ` + | ||
| `Please try again later or contact administrator."}}`, | ||
| )) | ||
| return | ||
| } | ||
| } | ||
| next.ServeHTTP(w, r) | ||
| }) | ||
| } | ||
|
Comment on lines
+631
to
+660
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker: can we remove the rate limiting behavior from this PR? It's not really needed today, so I'd prefer to avoid adding it. |
||
|
|
||
| // Start starts the Virtual MCP Server and begins serving requests. | ||
| // | ||
| //nolint:gocyclo // Complexity from health monitoring and startup orchestration is acceptable | ||
|
|
@@ -667,6 +751,19 @@ func (s *Server) Start(ctx context.Context) error { | |
| } | ||
| } | ||
|
|
||
| // Start idle session reaper if V2 session management is active with an idle timeout. | ||
| if mgr, ok := s.vmcpSessionMgr.(*sessionmanager.Manager); ok && s.config.IdleSessionTimeout > 0 { | ||
| idleCtx, idleCancel := context.WithCancel(ctx) | ||
| mgr.StartIdleReaper(idleCtx, defaultIdleCheckInterval) | ||
| slog.Info("idle session reaper started", | ||
| "idle_timeout", s.config.IdleSessionTimeout, | ||
| "check_interval", defaultIdleCheckInterval) | ||
| s.shutdownFuncs = append(s.shutdownFuncs, func(context.Context) error { | ||
| idleCancel() | ||
| return nil | ||
| }) | ||
| } | ||
|
|
||
| // Start status reporter if configured | ||
| if s.statusReporter != nil { | ||
| shutdown, err := s.statusReporter.Start(ctx) | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.