internal/mcpgateway/sharedsessions.go
Rama main · réplica de lectura del repositorio original.
package mcpgateway
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Downstream sessions are shared between replicas through the shared store.
// The replica that opens a session stores its binding (principal, endpoint,
// scope, and the client's initialize parameters) once initialize succeeds.
// A replica that gets a request for a session it does not host reads that
// record, checks the request against it, and rebuilds the session from it.
// Closing a session writes a close mark the other replicas check, so a
// session ends everywhere, not only where the DELETE landed.
const sharedSessionNamespace = "mcp.sessions"
const (
// sessionRecheckInterval bounds how long a replica keeps serving a session
// another replica closed: it rereads the close marks at most this often.
sessionRecheckInterval = 2 * time.Second
// sessionTouchInterval bounds how often a replica extends a session's
// shared expiry, so an active session costs one write a minute, not one
// per request.
sessionTouchInterval = time.Minute
// sharedSessionTimeout bounds each shared-store call on the request path.
sharedSessionTimeout = 2 * time.Second
// sessionCloseMarkLifetime keeps close marks past the last expiry a
// record they shadow can still have.
sessionCloseMarkLifetime = 2 * sessionIdleTimeout
// sessionUnverifiedLimit bounds how long a replica keeps serving a hosted
// session whose close marks it cannot read. It is shorter than a close
// mark lives, so a session closed elsewhere during an outage ends on its
// host before its mark expires, and is never extended back to life.
sessionUnverifiedLimit = sessionIdleTimeout
)
// sharedSession is the shared-store record of one session. It holds the
// binding and what the SDK needs to restore the session, never a secret.
type sharedSession struct {
AuthKeyID string `json:"auth_key_id,omitempty"`
UserPath string `json:"user_path,omitempty"`
Endpoint string `json:"endpoint,omitempty"`
// Scoped marks a session opened with ScopeHeader; Servers is its set.
Scoped bool `json:"scoped,omitempty"`
Servers []string `json:"servers,omitempty"`
Discovery bool `json:"discovery,omitempty"`
// VirtualIncarnation is sessionBinding.virtualIncarnation.
VirtualIncarnation string `json:"virtual_incarnation,omitempty"`
Initialize *mcp.InitializeParams `json:"initialize"`
ProtocolVersion string `json:"protocol_version"`
}
// scope rebuilds the visibility inputs the session was opened with: later
// requests never change a session's view, wherever they land.
func (r sharedSession) scope() requestScope {
scope := requestScope{authKeyID: r.AuthKeyID, userPath: r.UserPath, discovery: r.Discovery}
if virtual, ok := strings.CutPrefix(r.Endpoint, virtualBindingPrefix); ok {
scope.virtual = virtual
} else {
scope.pinned = r.Endpoint
}
if r.Scoped {
scope.include = make(map[string]struct{}, len(r.Servers))
for _, name := range r.Servers {
scope.include[name] = struct{}{}
}
}
return scope
}
// Keys hold a digest of the session ID, never the ID: it is a bearer token
// for the session, and the shared store is readable by every replica.
func sessionDigest(sessionID string) string {
sum := sha256.Sum256([]byte(sessionID))
return hex.EncodeToString(sum[:])
}
func sessionKey(sessionID string) string { return "session." + sessionDigest(sessionID) }
func sessionCloseKey(sessionID string) string { return "closed." + sessionDigest(sessionID) }
// endpointCloseKey names one incarnation of a virtual server endpoint: "/"
// never occurs in a virtual server name.
func endpointCloseKey(endpoint, incarnation string) string {
return "endpoint." + endpoint + "/" + incarnation
}
type sessionAdmission int
const (
// sessionAdmitted passes the request to the session's host: the resumed
// session admitSession returns, or else the SDK handler, which answers
// 404 for an ID it does not know.
sessionAdmitted sessionAdmission = iota
// sessionRejected answers 404.
sessionRejected
// sessionEnded answers 204: a DELETE closed a session no replica-local
// host had to close.
sessionEnded
)
// admitSession checks a request's session ID against the binding the session
// was opened under, on this replica or in the shared store, and finds its
// host. remove marks a DELETE, which ends the session on every replica.
func (s *Service) admitSession(ctx context.Context, sessionID, authKeyID, userPath, endpoint string, remove bool) (sessionAdmission, *resumedSession) {
binding, ok := s.liveBinding(sessionID)
if !ok {
if s.shared == nil {
// Unknown session IDs pass through: the SDK rejects them itself,
// and bindings do not survive restarts.
return sessionAdmitted, nil
}
return s.admitSharedSession(ctx, sessionID, authKeyID, userPath, endpoint, remove)
}
if !binding.boundTo(authKeyID, userPath, endpoint) {
return sessionRejected, nil
}
if s.endpointReplaced(binding.endpoint, binding.virtualIncarnation) {
s.unbind(sessionID, binding.server)
closeSessions(binding.server)
return sessionRejected, nil
}
if remove {
s.unbind(sessionID, binding.server)
if binding.shared {
s.closeSharedSession(ctx, sessionID)
}
return sessionAdmitted, binding.resumed
}
if binding.shared && !s.recheckSharedSession(ctx, sessionID, binding) {
s.unbind(sessionID, binding.server)
closeSessions(binding.server)
return sessionRejected, nil
}
return sessionAdmitted, binding.resumed
}
// admitSharedSession admits a request for a session this replica does not
// host by its shared record, resuming the session here.
func (s *Service) admitSharedSession(ctx context.Context, sessionID, authKeyID, userPath, endpoint string, remove bool) (sessionAdmission, *resumedSession) {
lookupCtx, cancel := context.WithTimeout(ctx, sharedSessionTimeout)
record, raw, err := s.loadSharedSession(lookupCtx, sessionID)
cancel()
if err != nil {
// Unverifiable is unknown: the client re-initializes.
slog.Warn("failed to read mcp session from the shared store", "error", err)
return sessionRejected, nil
}
if record == nil {
return sessionAdmitted, nil
}
if record.AuthKeyID != authKeyID || record.UserPath != userPath || record.Endpoint != endpoint ||
s.endpointReplaced(record.Endpoint, record.VirtualIncarnation) {
return sessionRejected, nil
}
if remove {
s.closeSharedSession(ctx, sessionID)
return sessionEnded, nil
}
host, err, _ := s.resumes.Do(sessionID, func() (any, error) {
return s.resumeSession(ctx, sessionID, record, raw)
})
if err != nil {
slog.Warn("failed to resume mcp session from the shared store", "error", err)
return sessionRejected, nil
}
return sessionAdmitted, host.(*resumedSession)
}
// loadSharedSession returns the shared record of a session that is neither
// closed nor expired, or nil.
func (s *Service) loadSharedSession(ctx context.Context, sessionID string) (*sharedSession, []byte, error) {
raw, found, err := s.shared.Get(ctx, sharedSessionNamespace, sessionKey(sessionID))
if err != nil || !found {
return nil, nil, err
}
var record sharedSession
if err := json.Unmarshal(raw, &record); err != nil {
return nil, nil, fmt.Errorf("decode shared mcp session: %w", err)
}
if record.Initialize == nil {
return nil, nil, fmt.Errorf("shared mcp session has no initialize parameters")
}
closed, err := s.sharedSessionClosed(ctx, sessionID, record.Endpoint, record.VirtualIncarnation)
if err != nil || closed {
return nil, nil, err
}
return &record, raw, nil
}
// sharedSessionClosed reports whether a session was closed on any replica:
// by a DELETE, or by deleting the incarnation of the virtual server it was
// opened on. The second mark ends that incarnation's sessions on replicas
// that still serve it until they reload, and names the incarnation rather
// than a time, so it never closes a server recreated under the same name,
// whenever it lands; endpointReplaced covers the recreated server once the
// mark expires.
func (s *Service) sharedSessionClosed(ctx context.Context, sessionID, endpoint, incarnation string) (bool, error) {
if _, closed, err := s.shared.Get(ctx, sharedSessionNamespace, sessionCloseKey(sessionID)); err != nil || closed {
return closed, err
}
if !strings.HasPrefix(endpoint, virtualBindingPrefix) {
return false, nil
}
_, closed, err := s.shared.Get(ctx, sharedSessionNamespace, endpointCloseKey(endpoint, incarnation))
return closed, err
}
// recheckSharedSession reports whether a hosted session is still open
// cluster-wide, and extends its shared expiry. Both run at a bounded rate, so
// most requests touch no database. A failed read keeps the session, whose
// binding is still checked locally on every request, without extending it,
// until the close marks have been unreadable for sessionUnverifiedLimit.
func (s *Service) recheckSharedSession(ctx context.Context, sessionID string, binding sessionBinding) bool {
recheck, touch := s.reserveRecheck(sessionID, binding.server)
if !recheck {
return true
}
readCtx, cancel := context.WithTimeout(ctx, sharedSessionTimeout)
closed, err := s.sharedSessionClosed(readCtx, sessionID, binding.endpoint, binding.virtualIncarnation)
cancel()
if err != nil {
// Keep serving for a while, but extend nothing: the session may have
// been closed elsewhere, and extending its record past the close
// mark's lifetime would let it resume once the mark expires.
slog.Warn("failed to recheck mcp session in the shared store", "error", err)
return !s.unverifiedTooLong(sessionID, binding.server)
}
if closed {
return false
}
s.markVerified(sessionID, binding.server)
if touch {
// A touch racing a DELETE elsewhere may rewrite a deleted record;
// the close mark still ends the session, since every read checks it.
s.extendSharedSession(ctx, sessionID, binding.record)
}
return true
}
// reserveRecheck claims the shared-store work a request for a hosted session
// is due: rereading its close marks (recheck) and extending its shared expiry
// (touch). Claiming under bindMu sends one of several concurrent requests to
// the store, not each of them.
func (s *Service) reserveRecheck(sessionID string, server *mcp.Server) (recheck, touch bool) {
now := s.now()
s.bindMu.Lock()
defer s.bindMu.Unlock()
binding, ok := s.bindings[sessionID]
if !ok || binding.server != server || !binding.shared || now.Sub(binding.checkedAt) < sessionRecheckInterval {
return false, false
}
binding.checkedAt = now
if touch = now.Sub(binding.touchedAt) >= sessionTouchInterval; touch {
binding.touchedAt = now
}
s.bindings[sessionID] = binding
return true, touch
}
// extendSharedSession pushes a session's shared expiry a full idle timeout
// out from now. The write gets its own deadline, so slow reads before it
// cannot use it up.
func (s *Service) extendSharedSession(ctx context.Context, sessionID string, record []byte) {
ctx, cancel := context.WithTimeout(ctx, sharedSessionTimeout)
defer cancel()
if err := s.shared.Put(ctx, sharedSessionNamespace, sessionKey(sessionID), record, s.now().Add(sessionIdleTimeout)); err != nil {
slog.Warn("failed to extend mcp session in the shared store", "error", err)
}
}
// trackPOST marks a POST in flight for a session this replica hosts and
// returns the func that ends it. The session's idle timeout is paused while
// the POST runs and restarts when it ends, and the shared record follows: it
// is extended every postTouchInterval during the POST, so a long call does
// not let it lapse, and once more at the end. A short call costs one timer.
func (s *Service) trackPOST(sessionID string) func() {
s.bindMu.Lock()
binding, ok := s.bindings[sessionID]
if ok {
binding.posts++
s.bindings[sessionID] = binding
}
s.bindMu.Unlock()
if !ok {
return func() {}
}
server := binding.server
var keepAlive *time.Timer
done := make(chan struct{})
if s.shared != nil {
keepAlive = time.AfterFunc(s.postTouchInterval, func() {
ticker := time.NewTicker(s.postTouchInterval)
defer ticker.Stop()
for {
s.touchHostedSession(sessionID, server)
select {
case <-done:
return
case <-ticker.C:
}
}
})
}
return func() {
if keepAlive != nil {
keepAlive.Stop()
}
close(done)
s.bindMu.Lock()
if current, ok := s.bindings[sessionID]; ok && current.server == server {
current.posts--
current.lastSeen = s.now()
s.bindings[sessionID] = current
}
s.bindMu.Unlock()
if s.shared != nil {
s.touchHostedSession(sessionID, server)
}
}
}
// touchHostedSession extends a hosted session's shared expiry unless an
// extension within the touch interval already did. Like reserveRecheck, it
// claims the extension under bindMu. It reads the close marks first: a call
// can outlast them, and extending a record another replica closed would let
// the session resume once they expire. A closed session ends here instead.
func (s *Service) touchHostedSession(sessionID string, server *mcp.Server) {
now := s.now()
s.bindMu.Lock()
binding, ok := s.bindings[sessionID]
due := ok && binding.server == server && binding.shared && now.Sub(binding.touchedAt) >= sessionTouchInterval
if due {
binding.touchedAt = now
s.bindings[sessionID] = binding
}
s.bindMu.Unlock()
if !due {
return
}
readCtx, cancel := context.WithTimeout(context.Background(), sharedSessionTimeout)
closed, err := s.sharedSessionClosed(readCtx, sessionID, binding.endpoint, binding.virtualIncarnation)
cancel()
if err != nil {
slog.Warn("failed to recheck mcp session in the shared store", "error", err)
}
if closed || (err != nil && s.unverifiedTooLong(sessionID, server)) {
s.unbind(sessionID, server)
closeSessions(server)
return
}
if err != nil {
return
}
s.markVerified(sessionID, server)
s.extendSharedSession(context.Background(), sessionID, binding.record)
}
// unverifiedTooLong reports whether a hosted session's close marks have been
// unreadable for sessionUnverifiedLimit. It reads the current binding, not
// the caller's snapshot, as a concurrent recheck may have succeeded meanwhile.
func (s *Service) unverifiedTooLong(sessionID string, server *mcp.Server) bool {
now := s.now()
s.bindMu.Lock()
defer s.bindMu.Unlock()
binding, ok := s.bindings[sessionID]
return !ok || binding.server != server || now.Sub(binding.verifiedAt) >= sessionUnverifiedLimit
}
// markVerified records a successful close-mark read for a hosted session.
func (s *Service) markVerified(sessionID string, server *mcp.Server) {
now := s.now()
s.bindMu.Lock()
if binding, ok := s.bindings[sessionID]; ok && binding.server == server {
binding.verifiedAt = now
s.bindings[sessionID] = binding
}
s.bindMu.Unlock()
}
// resumeSession rebuilds a session from its shared record and hosts it here.
// The SDK restores the negotiated state it carries, so the session continues
// without a new initialize.
func (s *Service) resumeSession(ctx context.Context, sessionID string, record *sharedSession, raw []byte) (*resumedSession, error) {
s.bindMu.Lock()
binding, ok := s.bindings[sessionID]
s.bindMu.Unlock()
if ok && binding.resumed != nil {
return binding.resumed, nil // resumed by a request that overlapped this one
}
scope := record.scope()
server, aliases := s.newSessionServer(scope)
transport := &mcp.StreamableServerTransport{SessionID: sessionID}
session, err := server.Connect(context.WithoutCancel(ctx), transport, &mcp.ServerSessionOptions{
State: &mcp.ServerSessionState{
InitializeParams: record.Initialize,
InitializedParams: &mcp.InitializedParams{},
NegotiatedProtocolVersion: record.ProtocolVersion,
},
})
if err != nil {
return nil, err
}
host := newResumedSession(session, transport)
// Resuming is activity: extend the expiry now rather than a minute later.
now := s.now()
s.extendSharedSession(ctx, sessionID, raw)
s.bindMu.Lock()
// Checked under bindMu, which closeResumedSessions takes after stopCtx is
// cancelled: either Close sees this binding or this sees Close.
if s.stopCtx.Err() != nil {
s.bindMu.Unlock()
host.close()
return nil, errServiceClosed
}
s.bindings[sessionID] = sessionBinding{
authKeyID: record.AuthKeyID,
userPath: record.UserPath,
endpoint: record.Endpoint,
lastSeen: now,
virtualIncarnation: record.VirtualIncarnation,
discovery: scope.discovery,
toolAliases: aliases,
server: server,
resumed: host,
shared: true,
record: raw,
checkedAt: now,
verifiedAt: now,
touchedAt: now,
}
s.bindMu.Unlock()
go s.awaitResumed(sessionID, host)
return host, nil
}
// awaitResumed drops a resumed session's binding once the session ends,
// however it ends: idle timeout, DELETE, or an endpoint closing.
func (s *Service) awaitResumed(sessionID string, host *resumedSession) {
_ = host.session.Wait()
host.stopTimer()
s.bindMu.Lock()
if binding, ok := s.bindings[sessionID]; ok && binding.resumed == host {
delete(s.bindings, sessionID)
}
s.bindMu.Unlock()
}
// closeResumedSessions ends the sessions this replica resumed, at Close. The
// replicas that remain resume them again on the client's next request.
func (s *Service) closeResumedSessions() {
s.bindMu.Lock()
var hosts []*resumedSession
for _, binding := range s.bindings {
if binding.resumed != nil {
hosts = append(hosts, binding.resumed)
}
}
s.bindMu.Unlock()
for _, host := range hosts {
go host.close()
}
}
// shareOnInitialize stores a new session's record once its initialize
// succeeds, before the response carries the session ID to the client, so
// any replica can resume it from the client's next request on.
func (s *Service) shareOnInitialize(scope requestScope) mcp.Middleware {
return func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
result, err := next(ctx, method, req)
if err != nil || method != "initialize" {
return result, err
}
initialize, ok := req.(*mcp.ServerRequest[*mcp.InitializeParams])
initialized, resultOK := result.(*mcp.InitializeResult)
if ok && resultOK && initialize.Session != nil {
s.shareSession(ctx, initialize.Session.ID(), scope, initialize.Params, initialized.ProtocolVersion)
}
return result, err
}
}
}
func (s *Service) shareSession(ctx context.Context, sessionID string, scope requestScope, params *mcp.InitializeParams, protocolVersion string) {
s.bindMu.Lock()
binding, ok := s.bindings[sessionID]
s.bindMu.Unlock()
if !ok || params == nil {
return
}
record := sharedSession{
AuthKeyID: binding.authKeyID,
UserPath: binding.userPath,
Endpoint: binding.endpoint,
Discovery: binding.discovery,
Initialize: params,
ProtocolVersion: protocolVersion,
}
record.VirtualIncarnation = binding.virtualIncarnation
if scope.include != nil {
record.Scoped = true
record.Servers = slices.Sorted(maps.Keys(scope.include))
}
raw, err := json.Marshal(record)
if err != nil {
slog.Warn("failed to encode mcp session for the shared store; it is served by this replica only", "error", err)
return
}
now := s.now()
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedSessionTimeout)
defer cancel()
if err := s.shared.Put(ctx, sharedSessionNamespace, sessionKey(sessionID), raw, now.Add(sessionIdleTimeout)); err != nil {
slog.Warn("failed to share mcp session; it is served by this replica only", "error", err)
return
}
s.bindMu.Lock()
if current, ok := s.bindings[sessionID]; ok && current.server == binding.server {
current.shared = true
current.record = raw
current.checkedAt = now
current.touchedAt = now
current.verifiedAt = now
s.bindings[sessionID] = current
}
s.bindMu.Unlock()
}
// closeSharedSession ends a session on every replica: the close mark stops
// the ones hosting it, and deleting the record stops new resumes.
func (s *Service) closeSharedSession(ctx context.Context, sessionID string) {
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedSessionTimeout)
defer cancel()
if err := s.shared.Put(ctx, sharedSessionNamespace, sessionCloseKey(sessionID), nil, s.now().Add(sessionCloseMarkLifetime)); err != nil {
slog.Warn("failed to close mcp session in the shared store", "error", err)
}
if err := s.shared.Delete(ctx, sharedSessionNamespace, sessionKey(sessionID)); err != nil {
slog.Warn("failed to delete mcp session from the shared store", "error", err)
}
}
// closeSharedEndpoint ends, on every replica, the sessions opened on one
// incarnation of endpoint; closeIncarnationSessions ends the ones this
// replica hosts.
func (s *Service) closeSharedEndpoint(ctx context.Context, endpoint, incarnation string) {
if s.shared == nil {
return
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), sharedSessionTimeout)
defer cancel()
if err := s.shared.Put(ctx, sharedSessionNamespace, endpointCloseKey(endpoint, incarnation), nil, s.now().Add(sessionCloseMarkLifetime)); err != nil {
slog.Warn("failed to close mcp endpoint sessions in the shared store", "endpoint", endpoint, "error", err)
}
}
// endpointIncarnation is the Incarnation of the virtual server endpoint
// names, as this replica serves it; empty for any other endpoint.
func (s *Service) endpointIncarnation(endpoint string) string {
virtual, ok := strings.CutPrefix(endpoint, virtualBindingPrefix)
if !ok {
return ""
}
spec, _ := s.virtualSpec(virtual)
return spec.Incarnation
}
// endpointReplaced reports whether the virtual server a session opened on,
// incarnation, was since deleted and recreated under its name. A virtual
// server this replica no longer serves is not replaced: ServeHTTP already
// answers 404 for it.
func (s *Service) endpointReplaced(endpoint, incarnation string) bool {
virtual, ok := strings.CutPrefix(endpoint, virtualBindingPrefix)
if !ok {
return false
}
spec, found := s.virtualSpec(virtual)
return found && spec.Incarnation != incarnation
}
// unbind drops a session's binding unless a newer host took its ID.
func (s *Service) unbind(sessionID string, server *mcp.Server) {
s.bindMu.Lock()
if binding, ok := s.bindings[sessionID]; ok && binding.server == server {
delete(s.bindings, sessionID)
}
s.bindMu.Unlock()
}
// closeSessions ends the sessions server hosts. Closing waits for in-flight
// requests to drain, so it runs in the background.
func closeSessions(server *mcp.Server) {
if server == nil {
return
}
for session := range server.Sessions() {
go func() { _ = session.Close() }()
}
}