Move SDK hooks and two-phase session creation under Serve - #5471
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5471 +/- ##
==========================================
+ Coverage 69.27% 69.29% +0.02%
==========================================
Files 638 638
Lines 64904 64931 +27
==========================================
+ Hits 44960 44992 +32
+ Misses 16622 16612 -10
- Partials 3322 3327 +5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Step P2.2 of the vMCP New/Serve split: the three mcp-go SDK hooks and the two-phase session-creation wiring are SDK-lifecycle concerns that belong in the transport layer, not the core VMCP domain object. This relocates them into Serve so they execute identically when Serve is exercised directly. server.New keeps its own copy until Phase 3, so its signature and observable behavior are unchanged. Implements changes for issue #5440: - Register OnRegisterSession + OnBeforeListTools/OnBeforeCallTool hooks in Serve, delegating to the unchanged *Server receiver methods - Build the transport session manager, session data storage (memory/Redis, THV_SESSION_REDIS_PASSWORD) with a close-on-error leak guard, and the vMCP session manager inside Serve - Add ServerConfig.SessionManagerConfig (pre-built *sessionmanager.FactoryConfig) and the shared BackendRegistry; fold the now-redundant SessionFactory/ OptimizerFactory/OptimizerConfig slots into FactoryConfig - Add Serve-level tests driving the relocated hooks + two-phase creation via the SDK lifecycle, plus buildSessionDataStorage coverage; the HTTP suite stays on server.New as its parity gate Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixed issues from code review: - HIGH: Split the t.Setenv redis case into its own non-parallel test and made TestBuildSessionDataStorage parallel, resolving the tparallel lint failure - MEDIUM: Validate BackendRegistry in Serve (fail loudly with ErrInvalidConfig instead of nil-panicking inside the OnRegisterSession hook); add a nil-registry validation case and a testMinimalServeConfig helper - MEDIUM: Document on SessionManagerConfig that the composition root must validate workflow definitions before assembling FactoryConfig, since Serve no longer runs validateWorkflows Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7b8d982 to
22452a6
Compare
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-agent review — Move SDK hooks + two-phase session creation under Serve
Reviewed by 4 specialist agents (Go correctness, vMCP architecture, test coverage, general quality). Codex cross-review skipped (CLI not installed).
This is a faithful, behavior-preserving relocation. The block moved into Serve is a near-byte-for-byte copy of server.New (server.go:338–522), differing only by a safe ctx→hookCtx rename. The SDK boundary holds — zero mcp-go imports leak onto core.VMCP. ServerConfig reshape reduces config surface by reusing the existing FactoryConfig. All acceptance criteria in #5440 are met and scope is well under the 400 LOC / 10 file budget. On a normal PR this would be an APPROVE; left as a COMMENT because the PR is a draft (and GitHub disallows self-approval).
Findings
| # | Severity | Finding | Location |
|---|---|---|---|
| M1 | MEDIUM | validateWorkflows obligation moved to composition root with no enforcement seam |
serve.go:117 |
| M2 | MEDIUM | Cross-pod re-injection path untested; PR description's "covered by integration suite" claim is inaccurate | serve_session_test.go:103 |
| L1 | LOW | sessionManager cleanup goroutine leaks on the Serve error path (asymmetric with the new storage guard) |
serve.go:206 |
| L2 | LOW | toolSessionState.lastSession written under mutex but never read (dead state) |
serve_session_test.go:45 |
| L3 | LOW | TestServeClosesStorageOnSessionManagerError name overstates — does not observe Close() |
serve_session_test.go:169 |
| L4 | LOW | Redis test assertion not bound to the Redis branch | serve_session_test.go:245 |
0 HIGH — no blocking issues. The two MEDIUM items are worth resolving (or consciously deferring) before this leaves draft: M2 is the only one really worth acting on in this PR — either add the missing test or correct the description. M1 and L1 are reasonable to track for Phase 3 (#5444) when the two wiring copies collapse. L2–L4 are minor test-quality nits. Details are in the inline comments.
Dropped below threshold (no action): a wording nit on the discovery.Middleware nil-deref comment (the named mechanism was independently verified accurate, just incomplete); a doc clarification that the telemetry provider is intentionally set in two places; and an optional .Times(1) hardening of the MakeSessionWithID mock.
Addresses #5471 review comments: - LOW serve.go (3383233591): build the transport sessionManager after the fallible buildSessionDataStorage/sessionmanager.New calls so its cleanup goroutine cannot leak on a Serve error path (symmetry with the storage guard) - MEDIUM serve_session_test.go (3383233579): add a Serve-level test covering the cross-pod empty-store inject branch of lazyInjectSessionTools (and the pod-local no-op branch), the path the prior tests did not reach - LOW serve_session_test.go (3383233598): remove dead mu/lastSession test state - LOW serve_session_test.go (3383233608): rename the storage-error test to match what it asserts (error returned when session manager construction fails) - LOW serve_session_test.go (3383233616): bind the redis test assertion to the connection-failure wrap so an unrelated error can't satisfy it Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
This is step P2.2 of the vMCP New/Serve split (Phase 2). The mcp-go SDK hooks and the
two-phase session-creation wiring are SDK-lifecycle concerns that belong in the transport
layer of the New/Serve split, not in the core
VMCPdomain object — but they still lived inserver.New. This PR relocates them underServeso they execute identically whenServeisexercised directly, keeping the mcp-go two-phase dance (anti-pattern #5) isolated behind the
transport boundary and out of the core interface.
Serveso the coreVMCPobject staysfree of mcp-go session concepts (hooks, two-phase creation, cross-pod Redis re-hydration).
OnRegisterSession,OnBeforeListTools,OnBeforeCallTool) and the session-creation wiring (transport session manager, session datastorage, vMCP session manager) from
server.NewintoServeinpkg/vmcp/server/serve.go.server.Newkeeps its own copy until Phase 3, so its signatureand observable behavior are unchanged; the relocated callbacks and collaborators keep
their existing shapes.
Closes #5440
Type of change
Test plan
task test) —pkg/vmcp/serverpasses with-racegolangci-lint run ./pkg/vmcp/server/...reports 0 issuesNew
Serve-level tests drive the relocated hooks and two-phase session creation through theSDK lifecycle directly (mounting the Streamable HTTP server on the relocated
mcpServer+vmcpSessionMgr, bypassing the not-yet-relocated discovery middleware that #5441/#5442 own):TestServeRegistersSessionHooks— an MCPinitializefiresOnRegisterSession, which runstwo-phase creation (
MakeSessionWithID) and injects the per-session tools so a subsequenttools/listadvertises them; theOnBeforeListToolshook runs and no-ops on the pod-localpath.
TestServeLazyInjectsToolsForRehydratedSession— covers the cross-pod re-injection branch:with a session registered in the vMCP session manager, a fresh SDK
ClientSessionwhoseper-session tool store is empty (as on a second pod, where
OnRegisterSessionnever fired)gets its tools re-injected by
lazyInjectSessionTools; a populated store is left untouched.TestServeReturnsErrorWhenSessionManagerConstructionFails— asserts Serve surfaces asessionmanager.Newfailure that occurs after the session data storage is built (the paththe
closeStorageOnErrguard protects). It confirms the guarded path is reached; it does notdirectly observe
Close()(the storage is built internally).TestBuildSessionDataStorage/TestBuildSessionDataStorageRedis— provider selection:nil/empty/
memory(case-insensitive) yields in-process storage;redistakes the Redis pathreading
THV_SESSION_REDIS_PASSWORD; unknown providers are rejected.The ~1.2k-line HTTP session-management integration suite stays on
server.New(unchanged) asthe behavioral-parity gate.
Changes
pkg/vmcp/server/serve.gobuildSessionDataStoragewith thecloseStorageOnErrguard, vMCP session manager) intoServe; reworkServerConfig(see below).pkg/vmcp/server/serve_session_test.goServe-level coverage of the relocated hooks + two-phase creation driven through the SDK lifecycle, plusbuildSessionDataStorageprovider selection.pkg/vmcp/server/serve_test.goServetests for the newServerConfigshape (SessionManagerConfig+BackendRegistry); add nil-BackendRegistryvalidation case; registert.Cleanupteardown.Implementation details
ServerConfigreshaped: added a pre-built*sessionmanager.FactoryConfigslot(
SessionManagerConfig) and a sharedBackendRegistry. The now-redundant standaloneSessionFactory/OptimizerFactory/OptimizerConfigslots were folded into theFactoryConfig. Both new collaborators are validated and fail loudly withvmcp.ErrInvalidConfigwhen nil —BackendRegistryin particular, because theOnRegisterSessionhook enumerates it inside a goroutine where the error would otherwise beswallowed.
*Serverreceiver methods(
handleSessionRegistration,lazyInjectSessionTools) are unchanged — they already operateagainst
s.vmcpSessionMgrand the SDKserver.ClientSession. The hooks object is built upfront (so it can be passed to
NewMCPServer) but its callbacks are registered aftersrvisassembled, since they close over
srv.buildSessionDataStoragekeeps thecloseStorageOnErrdefer so the storage's background cleanup goroutine is released on any error path out of
Serve(pairs acquisition with release).VMCPinterface; the "fixed at initialize"capability set, identity binding, and the cross-pod Redis re-hydration path stay in
Serve/session.
Does this introduce a user-facing change?
No. This relocates internal transport wiring within
pkg/vmcp/server;server.New's signatureand observable behavior are unchanged.
Special notes for reviewers
vmcp-core-p2-1_issue_5439(notmain) so the diff contains only this issue's two commits.*Serverhas a live session manager and backend registry but a still-nildiscovery manager / router / backend client at this phase. It must not be
Start()ed orserved on the
/MCP route until P2.3 Move middleware chain under Serve; remove authz + annotation mw #5441/P2.4 Replace discovery-into-context with direct VMCP calls #5442 wire those fields; the unauthenticated routesremain safe to serve. The new tests mount the Streamable HTTP server directly to stay within
this task's scope.
(P2.3 Move middleware chain under Serve; remove authz + annotation mw #5441), the direct
VMCPrequest path (P2.4 Replace discovery-into-context with direct VMCP calls #5442), and the AS runner / status reporter /optimizer / health monitor lifecycle (P2.5 Move AS runner, status reporter, optimizer, health monitor under Serve #5443). Phase 3 (P3.1 deriveCoreConfig/deriveServerConfig config split #5444) reduces
server.Newto aServe(ctx, New(...), ...)wrapper.