@@ -180,7 +180,38 @@ type Config struct {
180180 // When provider is "redis", a Redis-backed store is created for cross-pod
181181 // session persistence; the Redis password is read from the
182182 // THV_SESSION_REDIS_PASSWORD environment variable.
183+ //
184+ // Mutually exclusive with DataStorage: setting both is rejected at New().
183185 SessionStorage * vmcpconfig.SessionStorageConfig
186+
187+ // DataStorage optionally injects a caller-supplied session metadata store,
188+ // bypassing the built-in memory/redis providers. When non-nil, the server
189+ // uses this store as-is and SessionStorage is ignored in its entirety (no
190+ // field of SessionStorage is consulted). Setting both DataStorage and a
191+ // non-empty SessionStorage.Provider is rejected at New() as ambiguous
192+ // configuration.
193+ //
194+ // Lifecycle: the caller owns it. The server does NOT call Close() on a
195+ // caller-supplied DataStorage, even on error paths in New() or during
196+ // Stop(). The caller is responsible for invoking Close() exactly once
197+ // after Server.Stop() returns (not before — the session manager may issue
198+ // final Update calls during Stop). The caller is likewise responsible for
199+ // configuring the store's TTL; cfg.SessionTTL applies only to the
200+ // transport-level session manager, not to the caller-supplied DataStorage.
201+ //
202+ // Sensitive material: the store holds HMAC-hashed token material and
203+ // other session metadata. Embedders should treat the backing datastore as
204+ // sensitive (dedicated credentials, encryption at rest, restricted read
205+ // access). Implementations must not include credentials in Close() error
206+ // messages — those errors are surfaced through Server.Stop().
207+ //
208+ // This seam lets embedders satisfy transportsession.DataStorage against
209+ // datastores other than the built-in providers (e.g. Postgres, DynamoDB)
210+ // without forking the server. It enables cross-replica session metadata
211+ // sharing when backed by a shared store; it does NOT solve cross-replica
212+ // message delivery — callers still need session affinity at the load
213+ // balancer for streaming responses.
214+ DataStorage transportsession.DataStorage
184215}
185216
186217// Server is the Virtual MCP Server that aggregates multiple backends.
@@ -223,10 +254,16 @@ type Server struct {
223254 sessionManager * transportsession.Manager
224255
225256 // sessionDataStorage is the pluggable key-value backend for session metadata.
226- // Currently always LocalSessionDataStorage (in-memory, single-process).
227- // Redis-backed storage for multi-pod deployments is not yet wired .
257+ // It may be LocalSessionDataStorage (in-memory, single-process), a Redis-backed
258+ // store, or a caller-supplied implementation injected via Config.DataStorage .
228259 sessionDataStorage transportsession.DataStorage
229260
261+ // sessionDataStorageCloser closes sessionDataStorage on shutdown. It is
262+ // set only when the server built the store itself (memory or redis
263+ // providers). When Config.DataStorage was supplied by the caller, this is
264+ // nil and the caller is responsible for closing the store.
265+ sessionDataStorageCloser func (context.Context ) error
266+
230267 // Capability adapter for converting aggregator types to SDK types
231268 capabilityAdapter * adapter.CapabilityAdapter
232269
@@ -256,21 +293,51 @@ type Server struct {
256293}
257294
258295// buildSessionDataStorage constructs the DataStorage backend from cfg.
259- // When cfg.SessionStorage is nil or provider is "memory" (or empty), local in-process
260- // storage is used. When provider is "redis", a Redis-backed store is created
261- // using the address, DB, and key prefix from cfg.SessionStorage; the password
262- // is read from the THV_SESSION_REDIS_PASSWORD environment variable.
263- // Any other provider value is a misconfiguration and returns an error.
264- func buildSessionDataStorage (ctx context.Context , cfg * Config ) (transportsession.DataStorage , error ) {
296+ //
297+ // Resolution order:
298+ //
299+ // 1. cfg.DataStorage (caller-supplied) takes precedence. When non-nil, the
300+ // store is returned as-is with a nil closer — the caller owns the
301+ // lifecycle. Setting both cfg.DataStorage and a non-empty
302+ // cfg.SessionStorage.Provider is rejected as ambiguous.
303+ // 2. cfg.SessionStorage.Provider "memory" (or empty, or nil SessionStorage):
304+ // local in-process storage is created.
305+ // 3. cfg.SessionStorage.Provider "redis": a Redis-backed store is created
306+ // using the address, DB, and key prefix from cfg.SessionStorage. The
307+ // password is read from the THV_SESSION_REDIS_PASSWORD environment
308+ // variable.
309+ // 4. Any other provider value is a misconfiguration and returns an error.
310+ //
311+ // For cases 2 and 3 (server-built stores), the returned closer wraps the
312+ // store's Close method. For case 1 (caller-supplied), the closer is nil.
313+ // New() routes the returned closer through Server.sessionDataStorageCloser
314+ // so Close is invoked on shutdown (and on New() error after this point) —
315+ // but only for server-built stores.
316+ func buildSessionDataStorage (
317+ ctx context.Context ,
318+ cfg * Config ,
319+ ) (transportsession.DataStorage , func (context.Context ) error , error ) {
320+ if cfg .DataStorage != nil {
321+ if cfg .SessionStorage != nil && cfg .SessionStorage .Provider != "" {
322+ return nil , nil , fmt .Errorf (
323+ "cannot set both Config.DataStorage and Config.SessionStorage.Provider (%q); pick one" ,
324+ cfg .SessionStorage .Provider )
325+ }
326+ return cfg .DataStorage , nil , nil
327+ }
265328 // Default to in-process storage when session storage is not configured,
266329 // or when the provider is explicitly "memory" or left empty.
267330 if cfg .SessionStorage == nil ||
268331 cfg .SessionStorage .Provider == "" ||
269332 strings .EqualFold (cfg .SessionStorage .Provider , "memory" ) {
270- return transportsession .NewLocalSessionDataStorage (cfg .SessionTTL )
333+ store , err := transportsession .NewLocalSessionDataStorage (cfg .SessionTTL )
334+ if err != nil {
335+ return nil , nil , err
336+ }
337+ return store , closerFor (store ), nil
271338 }
272339 if cfg .SessionStorage .Provider != "redis" {
273- return nil , fmt .Errorf ("unsupported session storage provider %q (supported: \" memory\" , \" redis\" )" ,
340+ return nil , nil , fmt .Errorf ("unsupported session storage provider %q (supported: \" memory\" , \" redis\" )" ,
274341 cfg .SessionStorage .Provider )
275342 }
276343 keyPrefix := cfg .SessionStorage .KeyPrefix
@@ -288,7 +355,19 @@ func buildSessionDataStorage(ctx context.Context, cfg *Config) (transportsession
288355 "db" , cfg .SessionStorage .DB ,
289356 "key_prefix" , keyPrefix ,
290357 )
291- return transportsession .NewRedisSessionDataStorage (ctx , redisCfg , cfg .SessionTTL )
358+ store , err := transportsession .NewRedisSessionDataStorage (ctx , redisCfg , cfg .SessionTTL )
359+ if err != nil {
360+ return nil , nil , err
361+ }
362+ return store , closerFor (store ), nil
363+ }
364+
365+ // closerFor adapts DataStorage.Close (no context) to the
366+ // func(context.Context) error signature used by Server.sessionDataStorageCloser.
367+ func closerFor (store transportsession.DataStorage ) func (context.Context ) error {
368+ return func (context.Context ) error {
369+ return store .Close ()
370+ }
292371}
293372
294373// New creates a new Virtual MCP Server instance.
@@ -412,16 +491,18 @@ func New(
412491 // keyed by the same session ID.
413492 sessionManager := transportsession .NewManager (cfg .SessionTTL , transportsession .NewStreamableSession )
414493
415- sessionDataStorage , err := buildSessionDataStorage (ctx , cfg )
494+ sessionDataStorage , sessionDataStorageCloser , err := buildSessionDataStorage (ctx , cfg )
416495 if err != nil {
417496 return nil , fmt .Errorf ("failed to create session data storage: %w" , err )
418497 }
419- // Close sessionDataStorage if New() returns an error after this point so the
420- // background cleanup goroutine does not leak.
421- closeStorageOnErr := true
498+ // If we built the store ourselves, close it when New() returns an error
499+ // after this point so the background cleanup goroutine does not leak.
500+ // For a caller-supplied store (sessionDataStorageCloser == nil), the
501+ // caller owns the lifecycle and we leave it untouched on every path.
502+ closeStorageOnErr := sessionDataStorageCloser != nil
422503 defer func () {
423504 if closeStorageOnErr {
424- _ = sessionDataStorage . Close ( )
505+ _ = sessionDataStorageCloser ( ctx )
425506 }
426507 }()
427508
@@ -486,6 +567,12 @@ func New(
486567 srv .shutdownFuncs = append (srv .shutdownFuncs , optimizerCleanup )
487568 }
488569
570+ // Store the session data storage closer on the Server so Stop() can invoke
571+ // it last (after session manager and discovery manager have stopped). For
572+ // a caller-supplied store this is nil and Stop() leaves it alone — the
573+ // caller owns the lifecycle.
574+ srv .sessionDataStorageCloser = sessionDataStorageCloser
575+
489576 // Register OnRegisterSession hook to inject capabilities after SDK registers session.
490577 // See handleSessionRegistration for implementation details.
491578 hooks .AddOnRegisterSession (func (ctx context.Context , session server.ClientSession ) {
@@ -848,8 +935,10 @@ func (s *Server) Stop(ctx context.Context) error {
848935
849936 // Close session data storage last: HTTP server is down (no new in-flight requests),
850937 // all other components have stopped (no further restore or liveness checks).
851- if s .sessionDataStorage != nil {
852- if err := s .sessionDataStorage .Close (); err != nil {
938+ // Only invoked when the server built the store itself; caller-supplied stores
939+ // (Config.DataStorage) are left for the caller to close.
940+ if s .sessionDataStorageCloser != nil {
941+ if err := s .sessionDataStorageCloser (ctx ); err != nil {
853942 errs = append (errs , fmt .Errorf ("failed to close session data storage: %w" , err ))
854943 }
855944 }
0 commit comments