Route server.New through core.New and Serve#5556
Conversation
There was a problem hiding this comment.
Large PR Detected
This PR exceeds 1000 lines of changes and requires justification before it can be reviewed.
How to unblock this PR:
Add a section to your PR description with the following format:
## Large PR Justification
[Explain why this PR must be large, such as:]
- Generated code that cannot be split
- Large refactoring that must be atomic
- Multiple related changes that would break if separated
- Migration or data transformationAlternative:
Consider splitting this PR into smaller, focused changes (< 1000 lines each) for easier review and reduced risk.
See our Contributing Guidelines for more details.
This review will be automatically dismissed once you add the justification section.
15f7968 to
ae70c0c
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5556 +/- ##
==========================================
- Coverage 69.97% 69.92% -0.05%
==========================================
Files 653 660 +7
Lines 66489 66805 +316
==========================================
+ Hits 46524 46713 +189
- Misses 16586 16724 +138
+ Partials 3379 3368 -11 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ae70c0c to
497126e
Compare
497126e to
45bc869
Compare
server.New was the vMCP transport god-object: a ~210-line constructor that inlined the mcp-go server, SDK hooks, two-phase session creation, the workflow composer, telemetry decoration, and the health monitor. Phases 1-2 extracted a stateless domain core (core.New) and a transport entry point (Serve) behind a stable seam, but server.New still ran the legacy inline path. This reduces server.New to the thin wrapper the RFC specifies, making New/Serve the single live path for every caller. What changed: - server.New now builds the health monitor at the composition root (A2), assembles the core via core.New(deriveCoreConfig(...)) and hands it to Serve(deriveServerConfig(...)). The 7-param signature is unchanged. - server.Config gains Aggregator and Authz fields (the only channel to feed the core under the frozen signature); cli/serve.go sets them, and a new authfactory.BuildAuthzConfig surfaces the *authz.Config the HTTP middleware is built from so the core admission seam enforces the same policy. - Elicitation's construction-order inversion (the SDK adapter wraps the mcp-go server Serve builds) is resolved with a late-bound requester bound after Serve. - The session factory config sets AdvertiseFromCore so the core is the single aggregator and the Serve-layer optimizer (not the factory decorator) advertises find_tool/call_tool. - Removed New's //nolint:gocyclo, the dead validateWorkflows, and the now-orphaned router/backendClient/handlerFactory/capabilityAdapter Server fields. - Reworked the server parity suite to exercise the core path: tools are sourced from a stub/real Aggregator and calls route through core.CallTool. Rebased onto #5561, which landed the prerequisites this reroute depends on: composite-tool workflow telemetry now lives in the core (core_telemetry.go's telemetryComposer), and passthrough-header forwarding on the Serve path is resolved as per-request (the shared backend client merges the captured headers). The integration harness it ships builds the core via core.New + Serve directly, so both it and this wrapper exercise the same path. The now-dead authz/annotation/discovery middleware and the factory aggregator are left in place (unreachable) and removed in a stacked follow-up PR. Part of #5445. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
45bc869 to
2b7a7fe
Compare
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: architecture-design, security, concurrency-performance, test-coverage, general-code-quality
Consensus Summary
| # | Finding | Score | Severity | Action |
|---|---|---|---|---|
| F1 | //nolint:gocyclo on Start() not removed per 302a AC |
8/10 | MEDIUM | Fix |
| F2 | AuthzMiddleware-without-Authz should be startup error, not WARN |
7/10 | MEDIUM | Fix |
| F3 | Goroutine leak if core.New succeeds but Serve fails |
7/10 | MEDIUM | Fix |
| F5 | Optimizer + Config.Authz combination has no guard |
7/10 | MEDIUM | Fix |
| F6 | No TestNew_NilAggregator_ReturnsError for newly required field |
7/10 | MEDIUM | Fix |
Overall
This PR correctly achieves its primary goal: server.New's body is now a thin composition root over core.New + Serve, and the behavioral-parity suite genuinely gates the live path for the first time. The late-bound elicitation requester is a clean, well-tested solution to the construction-order inversion. The Cedar authz DRY refactor and the new authz_integration_test.go are high-quality additions.
Five MEDIUM findings warrant attention before merge. Two carry security risk: the AuthzMiddleware-without-Authz guard degrades to allow-all with only a log warning (F2), and the Optimizer + Config.Authz combination lacks a construction-time mutual-exclusion check that the admission layer docs require (F5). One is a resource-management correctness issue in an error path: core.New's background state-store goroutine leaks if Serve subsequently fails (F3). One is a missed 302a acceptance-criteria item: the //nolint:gocyclo on (*Server).Start() was listed as a 302a deliverable in the split note but was not removed (F1). One is a missing test for the new required Config.Aggregator field (F6).
The intentionally-deferred 302b items (A1 dead-code cleanup, session.WithAggregator removal, ProcessPreQueriedCapabilities removal) are correctly scoped out. The AdvertiseFromCore: true guard for double-aggregation is the right interim approach, but should be explicitly verified before 302b lands.
F1: //nolint:gocyclo on Start() not removed
The split note for TASK-302a explicitly lists "remove the two //nolint:gocyclo" as a 302a deliverable. The directive on New was correctly removed. The one on (*Server).Start() at server.go:688 was not removed. Run task lint after removing it: if lint passes (the complexity moved to Serve), remove it; if it fires, the AC claim needs updating.
Generated with Claude Code
Addresses #5556 review comments: - MEDIUM pkg/vmcp/server/server.go (3455015667): F2 — return an error instead of WARN when AuthzMiddleware is set without Config.Authz, so the vestigial middleware can never silently degrade to allow-all on the New/Serve path. - MEDIUM pkg/vmcp/server/server.go (3455015695): F5 — reject Config.Authz combined with Config.OptimizerConfig (ErrInvalidConfig) per the admission seam contract; optimizer meta-tools have no Cedar policy representation. - MEDIUM pkg/vmcp/server/server.go (3455015686): F3 — close the core on a Serve failure so its state-store cleanup goroutine cannot leak (mirrors Serve's closeStorageOnErr guard). - MEDIUM pkg/vmcp/server/server.go (F1, review body): remove the now-unneeded //nolint:gocyclo on (*Server).Start(); lint passes without it. - MEDIUM pkg/vmcp/server/server_test.go (3455015705): F6 — add TestNew_NilAggregator_ReturnsError for the now-required Config.Aggregator. Also adds error tests for the F2/F5 guards and updates TestNewIgnoresVestigialAuthzMiddleware to set Config.Authz (now required alongside AuthzMiddleware). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
F1 addressed in f5cfa7b — removed the |
Large PR justification has been provided. Thank you!
|
✅ Large PR justification has been provided. The size review has been dismissed and this PR can now proceed with normal review. |
jerm-dro
left a comment
There was a problem hiding this comment.
Two blockers (the per-call aggregation below, plus the health-ownership one inline on the monitor wiring), with inline suggestions, a nitpick, and a question. The reroute itself is sound — these are mostly about what flipping to the single live path activates.
blocker: per-call backend re-aggregation on the Serve path
First — apologies we didn't flag this earlier; it's hard to see until the path comes together, since the per-call aggregation only becomes live once server.New routes through the core.
This PR makes core.New + Serve the single live path, changing backend discovery from once-per-session to once-per-call: coreToolHandler (serve_handlers.go:196) → core.CallTool (core_calls.go:40) → aggregatedView → AggregateCapabilities → QueryAllCapabilities, a full tools/list sweep of every backend on every tools/call (and resources/read). The legacy path swept once at initialize and routed calls through the session's cached routing table, so a session with M calls goes from ~1 sweep to ~M+1. The result-only parity suite won't surface this.
Possible solution: a transparent caching decorator wrapping the aggregator.Aggregator, applied at composition below the core — AggregateCapabilities serves a cached view on hit and only sweeps backends on miss/TTL-expiry. Core and Serve stay unchanged; no loopback to the server, and it needn't be session-scoped. Backend enumeration is identity-dependent, so the cache key MUST include identity — at the granularity that actually drives enumeration (subject + forwarded scopes/credential, not just the backend set). A backend-set-only key would serve one user's capability view to another. Keyed that way it is shared across a single user's sessions without leaking across users; a TTL bounds staleness, matching legacy's once-per-session freshness. (This memoizes the per-identity enumeration the call path already does correctly — same behavior, without the M+1 backend cost.)
nitpick: prefer deleting now-dead code in the same PR
Now that this PR routes every caller through Serve, the authz/annotation blocks in (*Server).Handler (~server.go:592) are dead — deriveServerConfig drops AuthzMiddleware, so the guard is always false. I would generally find it clearer to delete code in the same PR where it becomes unused, rather than leaving an always-false guard on main that reads as load-bearing until 302b (you already did this for New's own orphaned fields / validateWorkflows). Pure deletions of provably-dead code are cheap to review, so folding it in carries near-zero risk. Keeping 302a behavioral and 302b mechanical-deletion is a defensible call too — flagging the preference for next time, not asking to re-cut the split.
Addresses #5556 review comments: - pkg/vmcp/server/server.go (3456622160): A — fix stale New doc comment (it said a WARN is logged; the guard now returns ErrInvalidConfig). - pkg/vmcp/server/server.go (3456622170): C — add a construction-root guard rejecting Config.Authz with an empty Config.Name, for a clearer error than core.New's deeper admission-seam message (which already enforces it). +TestNew_AuthzWithoutName_ReturnsError. - pkg/vmcp/server/server.go (F, review body): delete the now-dead authz and annotation-enrichment blocks in (*Server).Handler (always false on the Serve path) and the orphaned annotation_enrichment.go + test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses #5556 review (body blocker — per-call re-aggregation): - The reroute makes core.New + Serve the live path, where the core re-derives the advertised view on every call (it holds no per-session cache). Without caching, each tools/call or resources/read triggers a full tools/list sweep of every backend, turning a session's M calls into ~M+1 sweeps. - Add aggregator.NewCachingAggregator: a transparent decorator wrapping the Aggregator below the core, memoizing AggregateCapabilities for a bounded TTL. The cache key is a SHA-256 of (subject, forwarded headers, backend IDs) so a caller's capability view is never served to another; errors are not cached; expired entries are evicted on miss; a nil/zero-TTL config disables it rather than masking the nil-aggregator check. - server.New wraps Config.Aggregator with it (capabilityCacheTTL = 30s); core and Serve are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses #5556 review (comment 3456622182, blocker): - The backend health monitor was owned by three places: server.New built it, Serve Start/Stopped it, and it was injected into both the core and Serve. Connecting to and tracking downstream backends is a domain concern the core already neighbors (registry, client, aggregation, and the capability filtering that already consumes health). - core.New now builds and starts the monitor from cfg.HealthMonitorConfig, filters capabilities with it (as before), stops it in Close, and exposes it via a new BackendHealth() health.Reporter accessor. - server.New/Serve no longer build, start, stop, or inject the monitor: buildHealthMonitor, the Server.healthMonitor/healthMonitorMu fields, ServerConfig.HealthMonitor, the typed-nil StatusProvider dance, and the deriveCoreConfig/deriveServerConfig health params are removed. The /status, /api/backends/health, and periodic status reporter read the monitor back through core.BackendHealth() via a thin (*Server).backendHealth() helper. - This reverses the #5443 split that homed the monitor's Start/Stop in Serve, giving the monitor one owner whose lifecycle is the core's (Close, already driven by Server.Stop). - Adds health.Reporter; relocates the Serve-path monitor-lifecycle tests to core-level tests (TestNew_HealthMonitorOwnedByCore / _DisabledWhenNil) and reworks the derive/health/status tests for the new wiring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Per-call re-aggregation (body blocker) addressed in b8dcc8c — added |
|
Dead authz/annotation Handler blocks (nitpick) folded into 1591269 — deleted both |
jerm-dro
left a comment
There was a problem hiding this comment.
Approving. Non-blocking follow-up: the entire s.core == nil legacy path is now unreachable dead code — Serve is the sole *Server constructor and always sets the core — so 302b should delete all of it (discovery middleware, backend_enrichment, GetAdaptedTools, and the session-factory aggregation/composite/telemetry mirrors), not just the annotation_enrichment removed in this PR.
The caching aggregator from the prior commit hand-rolled its own map + mutex + TTL eviction. The repo already depends directly on github.com/hashicorp/golang-lru/v2 (used by pkg/cache), so leverage it instead of reinventing the cache mechanics. - Replace the hand-rolled map/mutex/write-time-sweep with lru.Cache, which provides thread-safety, LRU eviction, and a hard size bound (capabilityCacheMaxEntries) — the size cap the previous implementation lacked. - Use the base (non-expirable) LRU plus a lazy TTL check on read, deliberately avoiding the expirable variant: it runs a perpetual background cleanup goroutine with no Stop, which a per-server cache would leak (the test suite runs under goleak). - Behavior, the identity-keyed cache key, and the public NewCachingAggregator surface are unchanged; the existing decorator tests pass as-is. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that #5445 (PR #5556) merged, Serve is the sole *Server constructor and always sets the core, so the legacy `s.core == nil` session path is unreachable dead code. Stage 1 of the dead-code removal tracked in #5621: - handleSessionRegistrationImpl: drop the `if s.core != nil` conditional and the legacy GetAdaptedTools/GetAdaptedResources registration tail — registration is now unconditionally injectCoreSessionCapabilities. - lazyInjectSessionTools: re-derive via serveSessionTools unconditionally (drop the GetAdaptedTools fallback). - Delete backend_enrichment.go (+test): withBackendEnrichment was guarded to s.core == nil, a no-op on the Serve path. - Delete the now-orphaned Manager.GetAdaptedTools/GetAdaptedResources/GetAdaptedPrompts and the two SessionManager interface methods. GetAdaptedPrompts was never wired (prompts unsupported), so it was already dead. The discovery middleware + context seam, the session-factory aggregation mirrors, and the server.New signature change are deferred to the follow-up stages in #5621. Part of #5621. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
server.Newwas the vMCP transport god-object: a ~210-line constructor that inlined themcp-go server, SDK hooks, two-phase session creation, the workflow composer, telemetry
decoration, and the health monitor (anti-pattern #3). Phases 1–2 (#5430/#5431) extracted a
stateless domain core (
core.New) and a transport entry point (Serve) behind a stableseam, and #5444 added the
deriveCoreConfig/deriveServerConfigprojections — butserver.Newstill ran the legacy inline path, so the behavioral-parity suite only everexercised the dead-end legacy chain rather than the live core path.
This PR reduces
server.New's body to the thin wrapper the RFC (THV-0076) specifies:Serve(ctx, core.New(deriveCoreConfig(...)), deriveServerConfig(...)). TheNew/Servesplit becomes the single live path for every caller, so the parity suite now genuinely
gates the core path. The 7-param
server.Newsignature is byte-for-byte unchanged.Newthrough the core.server.Newbuilds the backend health monitor at thecomposition root (A2), assembles the domain core via
core.New(deriveCoreConfig(...)),and hands it to
Serve(deriveServerConfig(...)). The health monitor is threaded both asa
health.StatusProviderinto the core (capability filtering) and as the lifecycle ownerinto
Serve(Start/Stop, P2.5 Move AS runner, status reporter, optimizer, health monitor under Serve #5443).AggregatorandAuthzfields toserver.Config— the only channel to supply the core without changing the signature.cli/serve.gopopulates both. A newauthfactory.BuildAuthzConfigsurfaces the*authz.Configthe core admission seam consumes, reusing the exact config the existingCedar HTTP middleware builder produces (DRY, allow-all parity preserved for the
no-policies case).
wraps the mcp-go server that
Servebuilds aftercore.New, but the core requires anon-nil
ElicitationRequesterat construction. A late-bound requester(
elicitation_latebound.go) is handed to the core and bound to the real adapter afterServereturns, before serving begins.FactoryConfig.AdvertiseFromCoreis set so thesession factory sources tools from the core and the Serve-layer optimizer (Wire the tool optimizer onto the Serve path #5543)
advertises
find_tool/call_tool, rather than the factory decorator aggregating inparallel.
New's//nolint:gocyclo, the deadvalidateWorkflows, and four orphanedServerfields(
router/backendClient/handlerFactory/capabilityAdapter) the legacy inline bodyheld.
a stub or real
Aggregatorand route calls throughcore.CallTool→ backend client,traversing identity binding — instead of the legacy chain.
Review feedback added two changes the reroute activates:
every call, so the reroute would otherwise sweep every backend's
tools/liston everytool call.
aggregator.NewCachingAggregatoris a transparent, identity-keyed, TTL-boundeddecorator wrapping the
Aggregatorbelow the core (the cache key includes the subject andforwarded credentials, so no caller's capability view leaks to another).
core.Newbuilds, starts, and(via
Close) stops the monitor and filters with it, exposing it through aBackendHealth()reporter; the server keeps only the
/health+/statusreporting routes. This gives themonitor one owner and removes the server-side
buildHealthMonitor, thehealthMonitor/healthMonitorMufields,ServerConfig.HealthMonitor, and the typed-nildance. (Reverses the P2.5 Move AS runner, status reporter, optimizer, health monitor under Serve #5443 split that homed the monitor's Start/Stop in Serve.)
Part of #5445.
Rebased onto #5561
This branch is rebased onto #5561, which landed the two Serve-path prerequisites this
reroute would otherwise have had to carry, so they are intentionally not in this diff:
toolhive_vmcp_workflow_*) now lives in the core(
core_telemetry.go'stelemetryComposer), reproducing the metrics the now-bypassedsession-layer composite decorator emitted.
the shared backend client merges the headers captured by
headerforward.CaptureMiddleware,and
TestVMCPServer_PassthroughHeadersasserts the per-request contract. Forward passthrough headers on the Serve path #5561 alsoswitched the integration harness (
NewVMCPServer) to build the core viacore.New + Servedirectly, so both it and this rerouted
server.Newexercise the same production path.Large PR Justification
This PR is one atomic refactor whose line count is dominated by tests, not production code:
server.goshrinks. 13 non-test files change(+555 / −510), and
server.goitself is a net reduction (+154 / −312, ≈158 linesremoved) — the goal is shrinking the
server.Newgod-object to a thin wrapper. The growthbeyond the bare reroute is the two review-driven changes above (the caching aggregator
decorator and moving the health monitor into the core), both small per-file deltas.
reworked to exercise the live
core.New + Servepath (it previously exercised the deadlegacy chain), and review feedback added regression tests (Cedar-authz integration,
late-bound elicitation, nil-aggregator, the construction-guard tests, the caching-decorator
tests, and the relocated core health-monitor lifecycle tests).
CLAUDE.mdexplicitly allowslarge PRs for test-heavy changes.
server.Newfrom the legacy inline path tocore.New + Serve; a partial application would leave the parity suite red, so the completeswitch is the smallest reviewable unit.
stacked follow-up (302b) removes the remaining now-dead discovery middleware and the factory
aggregator. Folding 302b in here would make this PR larger, not smaller.
Type of change
Test plan
task test) — full repo suite passes (exit 0)task lint) — clean except a pre-existing, unrelatedG115incmd/thv/app/upgrade.go(not touched by this PR)go test ./test/integration/vmcp/...)go test -race -count=1) acrosspkg/vmcp/server,pkg/vmcp/core,test/integration/vmcp— no data racesRegression verification
This is the highest-integration-risk PR in the epic: it retires the legacy path and routes
every caller through
core.New + Serve, so the acceptance gate is behavioral parity.The reroute decomposes into four links — wiring (
derive*), core domain logic, Servetransport, and end-to-end — each independently covered. Verification performed for this PR:
TestServe*/TestIntegration_*server-package suites and thetest/integration/vmcpend-to-end suitepreviously exercised the dead-end legacy chain; after the reroute they traverse
server.New → core.New + Servefor real —tools/list,tools/call, resources, prompts,composite workflows, optimizer mode, session lifecycle/termination, token binding, audit
logging, telemetry, and the real-backend path.
AuthzMiddlewareonto the core admission seam (Config.Authz). Coverage spans the wiring(
TestDeriveCoreConfigMapsAllFieldsasserts the exact*authz.Configreaches the core,plus the raw-server-name and nil→allow-all cases), the enforcement (
core/admission_test.gowith a real Cedar authorizer and annotation-gated policies), and the handler mapping
(
TestServeCoreToolHandlergenericizes the denial and asserts the authorizer detail neverleaks;
TestServeToolCallTerminatesOnBindingFailureproves the fail-closed binding check).New
TestIntegration_RealBackend_CedarAuthzGatesToolCallcloses the integration gap:it boots
server.Newwith a real Cedar policy over HTTP and proves both core gates — thelist filter (an unpermitted tool is filtered out, unadvertised, and uncallable) and the
call gate (an advertised tool's arg-gated call is denied with the generic message and no
detail leak).
reroute showed
NewandbuildHealthMonitorat 100% butlateBoundElicitationRequester.RequestElicitation(the new elicitation indirection everycomposite elicitation flows through) at 0%. New
elicitation_latebound_test.gocoversits not-bound guard, verbatim delegation, and concurrent bind/read under
-race→ 100%.cli/serve.go, whose tests pass). The externalbrood-boxembedder is the only out-of-tree caller — see the user-facing-change note below.Does this introduce a user-facing change?
The Go signature of
server.Newis unchanged, but its runtime contract changed forout-of-tree embedders (notably the external
brood-boxembedder):Config.Aggregatoris now required —core.Newfails without it, because the core isthe single source of the advertised capability set.
Config.AuthzMiddlewareis now vestigial on theNew/Servepath:Serveneverapplies it. Authorization is enforced by the core admission seam from
Config.Authz. Anembedder that previously enforced Cedar authz only via
AuthzMiddlewaremust setConfig.Authzinstead. SettingAuthzMiddlewarewithoutAuthzis now a constructionerror (
server.Newreturnsvmcp.ErrInvalidConfig) rather than a logged warning, so thesilent allow-all fails fast instead of degrading quietly.
Config.Authzcombined withConfig.OptimizerConfigis now rejected at construction(
vmcp.ErrInvalidConfig): the optimizer meta-tools (find_tool/call_tool) have no Cedarrepresentation, so the combination is mutually exclusive per the core admission-seam contract.
In-tree (
cli/serve.go) both fields are set and the optimizer is not combined with authz, sono behavior changes for the CLI or operator. The new construction errors require coordination
with the
brood-boxmaintainers.Special notes for reviewers
This is the highest-integration-risk PR in the epic: it is the moment the legacy path is
retired and every request flows through the
core.New+Servesplit for real. The fullbehavioral-parity suite is the acceptance gate.
PR 1 of a 2-PR split of #5445. The issue itself recommends the split because the full
change exceeds the 400 LOC / 10 file limit. This PR (302a) reroutes
server.Newand reworksthe parity suite. The stacked follow-up PR (302b) performs the now-safe dead-code removal —
nothing in 302b is reachable after this PR:
authz and annotation-enrichment blocks were already deleted in this PR per review; discovery
is entangled with the context-coupled router, so it stays for 302b).
session.WithAggregatorand the session factory'saggregatorfield(making the AC2 "factory must not aggregate" contract structural rather than a comment).
Aggregator.ProcessPreQueriedCapabilities.cli/serve.go'sWithAggregator(agg)factory wiring.server.Config.AuthzMiddlewareis intentionally kept as a vestigial input: thecli/serve.goassignment stays (set alongsideConfig.Authz), the HTTP blocks that read itwere removed in this PR, and
deriveServerConfigomits it so theServepath never appliesit. Setting it without
Config.Authzis now a construction error (see the user-facing change).