You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Relocate the three mcp-go SDK hooks and the two-phase session-creation wiring out of server.New and into the new Serve transport helper. The OnRegisterSession hook
(capability registration) and the OnBeforeListTools/OnBeforeCallTool hooks (cross-pod
Redis re-hydration) — together with the transport session manager, the session data
storage backend, and the vMCP session manager — are SDK-lifecycle concerns that belong in
the transport layer of the New/Serve split, not in the core VMCP domain object.
Context
This is step P2.2 of Phase 2. #5439 introduced Serve(ctx, VMCP, *ServerConfig) -> *Server plus the mcp-go server, route mux, and HTTP lifecycle skeleton, but the SDK hooks
and session creation still live in server.New. This task moves them under Serve.
mcp-go's two-phase session creation is the canonical SDK-coupling anti-pattern (#5): the
session ID is sent to the client during initialize, and the OnRegisterSession hook then
fires after registration to attach the per-session capability set. Because the SDK only
fires OnRegisterSession during initialize, a session re-hydrated from Redis on a
different pod has an empty per-session tool store; the OnBeforeListTools/ OnBeforeCallTool hooks lazily re-inject the tools before the SDK handler reads them. All
of this — hooks, two-phase dance, heartbeat, session-ID manager, and the Redis path — must
stay isolated inside Serve/the adapter and never cross the VMCP boundary (architecture.md
Core Principle #1; research.md lines 221-226).
The session layer's "fixed at initialize" capability set, identity binding
(MetadataKeyIdentityBinding), and the cross-pod Redis re-hydration path stay in
Serve/session. server.New remains unchanged in observable behavior throughout Phase 2.
Parent Story: #5431 Dependencies: #5439 (needs the Serve skeleton + ServerConfig to register hooks into) Blocks: #5442
Acceptance Criteria
The three SDK hooks (AddOnRegisterSession -> handleSessionRegistration, AddBeforeListTools / AddBeforeCallTool -> lazyInjectSessionTools) are registered
from within Serve (on the server.Hooks{} wired into the mcp-go server).
Two-phase session creation wiring lives under Serve: the transport session manager
(transportsession.NewManager), the session data storage backend
(buildSessionDataStorage, memory/Redis incl. THV_SESSION_REDIS_PASSWORD), and the vMCP
session manager (sessionmanager.New).
Session-scoped state — the "fixed at initialize" capability set, identity binding, and
the cross-pod Redis re-hydration path — remains in Serve/session; nothing about hooks,
two-phase creation, or session lifecycle crosses the VMCP boundary (no mcp-go types on
the interface).
Session-management integration tests stay green.
PR is ≤ 400 LOC and ≤ 10 files changed (excluding tests/docs/generated)
server.New signature and observable behavior unchanged
All tests pass (task test); lint clean (task lint-fix)
Code reviewed and approved
Technical Approach
Recommended Implementation
Move the hook registration block and the session-creation wiring from server.New into Serve (in pkg/vmcp/server/serve.go), keeping the *Server receiver methods
(handleSessionRegistration / handleSessionRegistrationImpl, lazyInjectSessionTools)
and the package-level helper setSessionToolsDirect (server.go:1048 — a package-level
func, not a *Server method) unchanged — they already operate against s.vmcpSessionMgr and the SDK server.ClientSession, so relocation is mechanical.
Sequence within Serve (matching today's order in server.New):
Construct the server.Hooks{} (today at server.go:330) and pass it to NewMCPServer
via server.WithHooks(hooks) (server.go:333-340) — these are co-located because the
hook callbacks close over srv, so register them after the *Server is assembled.
Build buildSessionDataStorage(ctx, cfg) (server.go:415 -> :265), preserving the
memory/Redis branch and the THV_SESSION_REDIS_PASSWORD read, and keep the closeStorageOnErr defer-guard so the cleanup goroutine does not leak on an error return
(go-style: pair acquisition with release).
Build sessionmanager.New(sessionDataStorage, sessMgrCfg, backendRegistry) (server.go:462)
and append optimizerCleanup to shutdownFuncs.
Register the three hooks (server.go:491-511) against the *Server so they delegate to
the receiver methods.
Keep this purely a relocation — do not change the hook callbacks, the two-phase semantics,
or the Redis re-hydration logic. The session-creation collaborators may be sourced from the ServerConfig slots #5439 established (SessionFactory, SessionStorage, SessionTTL,
optimizer factory/config). Because server.New is not yet routed through Serve (that is
Phase 3), server.New keeps its own copy until P3.2; this task wires Serve so the hooks +
session creation execute identically when Serve is exercised directly.
Patterns & Frameworks
mcp-go SDK boundary stays in Serve/the adapter — hooks, two-phase creation, and the THV_SESSION_REDIS_PASSWORD Redis path are transport concerns (RFC transport-extension
case Implement secret store #4, "intentionally closed"; architecture.md Core Principle fix(typo): corrects readme #1).
Anti-pattern Implement secret injection #5: the two-phase dance must remain invisible to the core; do not let
SDK-specific session concepts leak onto the VMCP interface.
Resource-leak guard: preserve the closeStorageOnErr defer so session data storage is
closed on any error path out of Serve (.claude/rules/go-style.md "Resource Leaks").
stdlib testing.T + testify in pkg/vmcp/server; no Ginkgo (architecture.md Core
Principle Create proxy subcommand #7 / R8). Mocks via gomock + task gen.
pkg/vmcp/server/server.go:489-511 — the three hook registrations
(AddOnRegisterSession, AddBeforeListTools, AddBeforeCallTool). Relocate into Serve.
pkg/vmcp/server/server.go:330,333-340 — hooks := &server.Hooks{} and NewMCPServer(... WithHooks(hooks)). The hooks object and its mcp-go server are co-located with the hook
registrations (callbacks close over srv).
pkg/vmcp/server/server.go:1130 — handleSessionRegistrationImpl (and handleSessionRegistration at :1103): phase-2 of two-phase creation; calls vmcpSessionMgr.CreateSession + GetAdaptedTools/GetAdaptedResources. Stays a *Server
method; no change.
pkg/vmcp/server/server.go:1078 — lazyInjectSessionTools: cross-pod Redis re-hydration
target of the OnBeforeListTools/OnBeforeCallTool hooks. Stays a *Server method; no
change.
pkg/vmcp/server/server.go:415 + :265 — buildSessionDataStorage (memory/Redis; THV_SESSION_REDIS_PASSWORD via vmcpconfig.RedisPasswordEnvVar). Relocate with the closeStorageOnErr guard.
research.md lines 221-226 — "Session-scoped concerns that MUST stay in Serve/session
layer" (capabilities fixed at initialize, identity binding, cross-pod Redis storage,
two-phase creation + SDK hooks + heartbeat + session-ID manager).
architecture.md lines 285-290 ("Serve (transport) wiring relocated"), 300-303 ("Session
layer … stays in Serve"), 318-321 ("fixed at initialize cache stays in Serve").
pkg/vmcp/server/session_management_integration_test.go (907 lines), session_management_realbackend_integration_test.go (261) — the integration suite that
must stay green; the parity gate for this task.
Component Interfaces
No interface changes. This is a relocation of existing wiring; the hook callbacks and the
session-creation collaborators keep their current shapes. The hooks attach to the SDK via
the mcp-go server.Hooks value already built in Serve (#5439) and delegate to the *Server receiver methods:
// Inside Serve (pkg/vmcp/server/serve.go), after srv is constructed.// hooks is the *server.Hooks passed to NewMCPServer(server.WithHooks(hooks)).// Phase-2 of two-phase creation: fires AFTER the SDK registers the session// (the Mcp-Session-Id has already been sent to the client during initialize).hooks.AddOnRegisterSession(func(ctx context.Context, session server.ClientSession) {
srv.handleSessionRegistration(ctx, session)
})
// Cross-pod Redis re-hydration: lazily re-inject tools when a session re-hydrated// from Redis on another pod has an empty per-session SDK tool store.hooks.AddBeforeListTools(func(ctx context.Context, _any, _*mcp.ListToolsRequest) {
srv.lazyInjectSessionTools(ctx)
})
hooks.AddBeforeCallTool(func(ctx context.Context, _any, _*mcp.CallToolRequest) {
srv.lazyInjectSessionTools(ctx)
})
// Session-creation wiring relocated from server.New (memory/Redis backend selection// stays here; THV_SESSION_REDIS_PASSWORD read inside buildSessionDataStorage).sessionManager:=transportsession.NewManager(ttl, transportsession.NewStreamableSession)
sessionDataStorage, err:=buildSessionDataStorage(ctx, cfg) // closeStorageOnErr guardvmcpSessMgr, optimizerCleanup, err:=sessionmanager.New(sessionDataStorage, sessMgrCfg, backendRegistry)
Testing Strategy
Unit Tests
lazyInjectSessionTools is a no-op when the per-session SDK tool store already has
tools (normal pod-local case), and injects from the vMCP session manager when the store is
empty (cross-pod case) — mirror the existing *Server-method coverage after relocation.
buildSessionDataStorage selects in-process storage for nil/empty/"memory" provider
and Redis (reading THV_SESSION_REDIS_PASSWORD) for "redis"; returns an error for any
other provider.
Integration / Behavioral Parity Tests
session_management_integration_test.go and session_management_realbackend_integration_test.go stay green: session registration,
tools/list and tools/call after initialize, and the cross-pod Redis re-hydration path
behave identically with hooks + session creation driven from Serve.
Edge Cases
On an error returned from Serve after the session data storage is built, the closeStorageOnErr guard closes the storage so the cleanup goroutine does not leak.
A session re-hydrated from Redis on a second pod (no initialize, empty SDK tool
store) gets its tools re-injected by the before-list/before-call hooks.
Identity binding (MetadataKeyIdentityBinding) and the "fixed at initialize"
capability set remain enforced by the session layer; none of it crosses the VMCP
boundary.
Description
Relocate the three mcp-go SDK hooks and the two-phase session-creation wiring out of
server.Newand into the newServetransport helper. TheOnRegisterSessionhook(capability registration) and the
OnBeforeListTools/OnBeforeCallToolhooks (cross-podRedis re-hydration) — together with the transport session manager, the session data
storage backend, and the vMCP session manager — are SDK-lifecycle concerns that belong in
the transport layer of the New/Serve split, not in the core
VMCPdomain object.Context
This is step P2.2 of Phase 2. #5439 introduced
Serve(ctx, VMCP, *ServerConfig) -> *Serverplus the mcp-go server, route mux, and HTTP lifecycle skeleton, but the SDK hooksand session creation still live in
server.New. This task moves them underServe.mcp-go's two-phase session creation is the canonical SDK-coupling anti-pattern (#5): the
session ID is sent to the client during
initialize, and theOnRegisterSessionhook thenfires after registration to attach the per-session capability set. Because the SDK only
fires
OnRegisterSessionduringinitialize, a session re-hydrated from Redis on adifferent pod has an empty per-session tool store; the
OnBeforeListTools/OnBeforeCallToolhooks lazily re-inject the tools before the SDK handler reads them. Allof this — hooks, two-phase dance, heartbeat, session-ID manager, and the Redis path — must
stay isolated inside
Serve/the adapter and never cross theVMCPboundary (architecture.mdCore Principle #1; research.md lines 221-226).
The session layer's "fixed at initialize" capability set, identity binding
(
MetadataKeyIdentityBinding), and the cross-pod Redis re-hydration path stay inServe/session.
server.Newremains unchanged in observable behavior throughout Phase 2.Parent Story: #5431
Dependencies: #5439 (needs the
Serveskeleton +ServerConfigto register hooks into)Blocks: #5442
Acceptance Criteria
AddOnRegisterSession->handleSessionRegistration,AddBeforeListTools/AddBeforeCallTool->lazyInjectSessionTools) are registeredfrom within
Serve(on theserver.Hooks{}wired into the mcp-go server).Serve: the transport session manager(
transportsession.NewManager), the session data storage backend(
buildSessionDataStorage, memory/Redis incl.THV_SESSION_REDIS_PASSWORD), and the vMCPsession manager (
sessionmanager.New).the cross-pod Redis re-hydration path — remains in Serve/session; nothing about hooks,
two-phase creation, or session lifecycle crosses the
VMCPboundary (no mcp-go types onthe interface).
server.Newsignature and observable behavior unchangedtask test); lint clean (task lint-fix)Technical Approach
Recommended Implementation
Move the hook registration block and the session-creation wiring from
server.NewintoServe(inpkg/vmcp/server/serve.go), keeping the*Serverreceiver methods(
handleSessionRegistration/handleSessionRegistrationImpl,lazyInjectSessionTools)and the package-level helper
setSessionToolsDirect(server.go:1048— a package-levelfunc, not a
*Servermethod) unchanged — they already operate againsts.vmcpSessionMgrand the SDKserver.ClientSession, so relocation is mechanical.Sequence within
Serve(matching today's order inserver.New):server.Hooks{}(today at server.go:330) and pass it toNewMCPServervia
server.WithHooks(hooks)(server.go:333-340) — these are co-located because thehook callbacks close over
srv, so register them after the*Serveris assembled.transportsession.NewManager(cfg.SessionTTL, transportsession.NewStreamableSession)(server.go:413).
buildSessionDataStorage(ctx, cfg)(server.go:415 -> :265), preserving thememory/Redis branch and the
THV_SESSION_REDIS_PASSWORDread, and keep thecloseStorageOnErrdefer-guard so the cleanup goroutine does not leak on an error return(go-style: pair acquisition with release).
sessionmanager.New(sessionDataStorage, sessMgrCfg, backendRegistry)(server.go:462)and append
optimizerCleanuptoshutdownFuncs.*Serverso they delegate tothe receiver methods.
Keep this purely a relocation — do not change the hook callbacks, the two-phase semantics,
or the Redis re-hydration logic. The session-creation collaborators may be sourced from the
ServerConfigslots #5439 established (SessionFactory,SessionStorage,SessionTTL,optimizer factory/config). Because
server.Newis not yet routed throughServe(that isPhase 3),
server.Newkeeps its own copy until P3.2; this task wiresServeso the hooks +session creation execute identically when
Serveis exercised directly.Patterns & Frameworks
Serve/the adapter — hooks, two-phase creation, and theTHV_SESSION_REDIS_PASSWORDRedis path are transport concerns (RFC transport-extensioncase Implement secret store #4, "intentionally closed"; architecture.md Core Principle fix(typo): corrects readme #1).
SDK-specific session concepts leak onto the
VMCPinterface.closeStorageOnErrdefer so session data storage isclosed on any error path out of
Serve(.claude/rules/go-style.md "Resource Leaks").testing.T+ testify inpkg/vmcp/server; no Ginkgo (architecture.md CorePrinciple Create
proxysubcommand #7 / R8). Mocks via gomock +task gen..claude/rules/go-style.md,.claude/rules/vmcp-anti-patterns.md,.claude/rules/security.md.Code Pointers
pkg/vmcp/server/server.go:489-511— the three hook registrations(
AddOnRegisterSession,AddBeforeListTools,AddBeforeCallTool). Relocate intoServe.pkg/vmcp/server/server.go:330,333-340—hooks := &server.Hooks{}andNewMCPServer(... WithHooks(hooks)). The hooks object and its mcp-go server are co-located with the hookregistrations (callbacks close over
srv).pkg/vmcp/server/server.go:1130—handleSessionRegistrationImpl(andhandleSessionRegistrationat :1103): phase-2 of two-phase creation; callsvmcpSessionMgr.CreateSession+GetAdaptedTools/GetAdaptedResources. Stays a*Servermethod; no change.
pkg/vmcp/server/server.go:1078—lazyInjectSessionTools: cross-pod Redis re-hydrationtarget of the
OnBeforeListTools/OnBeforeCallToolhooks. Stays a*Servermethod; nochange.
pkg/vmcp/server/server.go:413—transportsession.NewManager(cfg.SessionTTL, transportsession.NewStreamableSession). Relocate.pkg/vmcp/server/server.go:415+:265—buildSessionDataStorage(memory/Redis;THV_SESSION_REDIS_PASSWORDviavmcpconfig.RedisPasswordEnvVar). Relocate with thecloseStorageOnErrguard.pkg/vmcp/server/server.go:462—sessionmanager.New(sessionDataStorage, sessMgrCfg, backendRegistry)+optimizerCleanupappended toshutdownFuncs. Relocate.pkg/vmcp/server/serve.go—Serve+ServerConfigfrom P2.1 Serve skeleton + ServerConfig #5439; the relocationtarget.
pkg/vmcp/server/server.go:301—server.New(7-param signature; stays stable, behaviorunchanged).
layer" (capabilities fixed at initialize, identity binding, cross-pod Redis storage,
two-phase creation + SDK hooks + heartbeat + session-ID manager).
layer … stays in Serve"), 318-321 ("fixed at initialize cache stays in Serve").
pkg/vmcp/server/session_management_integration_test.go(907 lines),session_management_realbackend_integration_test.go(261) — the integration suite thatmust stay green; the parity gate for this task.
Component Interfaces
No interface changes. This is a relocation of existing wiring; the hook callbacks and the
session-creation collaborators keep their current shapes. The hooks attach to the SDK via
the mcp-go
server.Hooksvalue already built inServe(#5439) and delegate to the*Serverreceiver methods:Testing Strategy
Unit Tests
lazyInjectSessionToolsis a no-op when the per-session SDK tool store already hastools (normal pod-local case), and injects from the vMCP session manager when the store is
empty (cross-pod case) — mirror the existing
*Server-method coverage after relocation.buildSessionDataStorageselects in-process storage for nil/empty/"memory" providerand Redis (reading
THV_SESSION_REDIS_PASSWORD) for "redis"; returns an error for anyother provider.
Integration / Behavioral Parity Tests
session_management_integration_test.goandsession_management_realbackend_integration_test.gostay green: session registration,tools/list and tools/call after initialize, and the cross-pod Redis re-hydration path
behave identically with hooks + session creation driven from
Serve.Edge Cases
Serveafter the session data storage is built, thecloseStorageOnErrguard closes the storage so the cleanup goroutine does not leak.initialize, empty SDK toolstore) gets its tools re-injected by the before-list/before-call hooks.
MetadataKeyIdentityBinding) and the "fixed at initialize"capability set remain enforced by the session layer; none of it crosses the
VMCPboundary.
Out of Scope
VMCP.ListTools/CallToolcalls — that is P2.4 Replace discovery-into-context with direct VMCP calls #5442 (which this task blocks).server.Newto theServe(ctx, New(...), ...)wrapper — that is Phase 3(P3.1 deriveCoreConfig/deriveServerConfig config split #5444 / P3.2).
MetadataKeyTokenHash/Salt(superseded byMetadataKeyIdentityBinding, vMCP terminates MCP session on every legitimate access-token refresh #5306) — out of this epic.References