Ctrl K
ComunidadGuías…

internal/mcpgateway/sharedsessions.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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() }()
	}
}