Forward passthrough headers on the Serve path#5561
Conversation
On the Serve path (core.New + Serve), tool/resource calls route through the shared backend client (pkg/vmcp/client) rather than through per-session backend connections. The shared client was not reading the passthrough headers captured by headerforward.CaptureMiddleware, and even if it had, it would read them per-request rather than using the session-creation-time snapshot — a behavioral and security regression vs. the legacy per-session-connection path (#5560). - Export MergeForwardedHeaders from pkg/vmcp/headerforward so both the session connector (legacy path) and the shared backend client (Serve path) share the same restricted-header and collision-detection rules. - Teach httpBackendClient.defaultClientFactory to merge ForwardedHeadersFromContext(ctx) with target.HeaderForward, enabling the Serve path to forward captured headers to backends. - Add capturedPassthroughHeaders sync.Map on Server (node-local, never persisted to Redis per security.md) to hold the session-creation-time header snapshot for each active session. - Capture the snapshot in handleSessionRegistrationImpl once per session when s.core != nil, and re-inject it via injectCapturedHeaders in coreToolHandler and coreResourceHandler before each backend call so mid-session header changes on the client side never reach the backend. - Register an AddOnUnregisterSession hook to clean up captured headers when the SDK unregisters a session. - Switch the integration test helper (NewVMCPServer) from server.New to core.New + Serve so tests exercise the production Serve path. - Wire workflow telemetry into core.New via a telemetryComposer wrapper (same metric names as the session-factory path) so existing telemetry tests continue to pass on the Serve path. - Re-enable TestVMCPServer_PassthroughHeaders on the Serve path; it now passes including the session-stability assertion (second call sees the session-creation-time value, not the mid-session changed value). Closes #5560 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Fix misleading TTL cleanup comment in serve.go: the OnUnregisterSession hook fires actively via the SDK's sessionIdleTTL sweeper, not lazily via sync.Map eviction (sync.Map has no eviction mechanism) - Canonicalize static plaintext keys in MergeForwardedHeaders so the returned map has uniformly canonical HTTP header names regardless of how the caller formatted the static config - Remove duplicate TestMergeForwardedHeaders from the session/internal/ backend package; the thin mergeForwardedHeaders wrapper delegates to headerforward.MergeForwardedHeaders whose full test matrix already lives in pkg/vmcp/headerforward/transport_test.go Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #5561 +/- ##
==========================================
- Coverage 70.05% 69.93% -0.13%
==========================================
Files 650 653 +3
Lines 66167 66489 +322
==========================================
+ Hits 46352 46496 +144
- Misses 16459 16636 +177
- Partials 3356 3357 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: security-reviewer, concurrency-reviewer, architecture-reviewer, test-coverage-reviewer, general-quality-reviewer
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| F1 | Defensive copy missing for captured headers | 8/10 | MEDIUM | Fix |
| F2 | Anti-pattern #1: context value used for domain data | 8/10 | MEDIUM | Fix (add comment) |
| F3 | Double-delete of session header entry undocumented | 8/10 | LOW | Fix (add comment) |
| F4 | "Base not mutated" test assertion missing | 8/10 | LOW | Fix |
| F5 | Static key canonicalization change undocumented | 7/10 | LOW | Fix (add comment) |
| F6 | No unit tests for injectCapturedHeaders |
6/10 | LOW | Fix |
| F7 | No unit tests for OnUnregisterSession cleanup hook |
6/10 | LOW | Fix |
| F8 | No unit tests for telemetryComposer |
6/10 | LOW | Fix |
Overall
This PR correctly fixes a security-relevant behavioral regression: the core.New + Serve path introduced in #5556 used a shared backend client that re-read forwarded headers per-request, breaking the session-stable guarantee that the legacy server.New path provided. The fix is targeted and sound — headers are captured once at session creation into a node-local sync.Map, injected before every backend call via injectCapturedHeaders, and cleaned up on session teardown. The PR also promotes the header-merge logic to a shared exported function (MergeForwardedHeaders), wires workflow telemetry onto the Serve path consistently with the session-factory path, and migrates the integration test helper to exercise the production code path. The re-enabled TestVMCPServer_PassthroughHeaders integration test directly verifies the acceptance criteria from #5560.
The two MEDIUM findings (F1, F2) are worth addressing but do not block merge — F1 is not a current bug and the fix is a one-line maps.Clone; F2 is a documentation comment. The LOW findings are unit test gaps for new production code (F6–F8) and documentation clarifications (F3–F5) that would improve defensibility of a credential-handling path.
Generated with Claude Code
Addresses #5561 review comments: - MEDIUM pkg/vmcp/server/server.go (3437978067): clone captured headers before storing; ForwardedHeadersFromContext returns the context map reference and structurally enforcing immutability is important on a credential path - MEDIUM pkg/vmcp/server/serve_handlers.go (3437978071): document why using context as the forwarding channel is an intentional anti-pattern #1 exception (SDK controls the handler context; backendClient has no explicit per-session header slot) - LOW pkg/vmcp/server/server.go (3437978080): clarify the double-delete timing invariant in the registration-failure defer — Store runs only after CreateSession succeeds, and the defer fires before the session is live, so the two delete paths are mutually exclusive for well-formed sessions - LOW pkg/vmcp/headerforward/transport.go (3437978095): document in MergeForwardedHeaders docstring that static plaintext keys are now canonicalized in the output (behaviorally identical at the wire level, but the intermediate map representation changed from the old maps.Copy path) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Addresses #5561 review comments: - LOW pkg/vmcp/headerforward/transport_test.go (3437978087): add per-case base-mutation guard to TestMergeForwardedHeaders; verifies that MergeForwardedHeaders never writes into the caller's AddPlaintextHeaders map - LOW pkg/vmcp/server/serve_handlers_test.go (3437978101): new file with TestInjectCapturedHeaders covering the hit, miss, and wrong-type-fallback branches of injectCapturedHeaders - LOW pkg/vmcp/server/serve_handlers_test.go (3437978106): TestOnUnregisterSession HookDeletesCapturedHeaders fires the hook closure via the SDK's UnregisterSession and asserts the capturedPassthroughHeaders entry is deleted - LOW pkg/vmcp/core/core_telemetry_test.go (3437978110): new file with TestTelemetryComposer_{Success,Error,DelegatesNonExecuteMethods} using an in-memory OTEL SDK reader to verify counter increments, histogram recording, and error-counter/span-error on failure Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
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.
|
I think this bundles a real fix with a re-implementation of incidental legacy behavior — flagging the latter before it lands. Real fix: on the Serve path, The rest isn't needed: the capture/ Proposal: keep the |
Per review feedback on #5561: session-stability was a side-effect of the legacy per-session connection, not an intended requirement. Per-request forwarding (each call reads the current incoming header value) is the correct semantic, and the client.go MergeForwardedHeaders call already achieves it. - Remove capturedPassthroughHeaders sync.Map and the session-creation capture logic from Server — the shared backend client reads ForwardedHeadersFromContext on every call, providing per-request forwarding without any extra machinery - Remove injectCapturedHeaders and its ctx replacement from coreToolHandler / coreResourceHandler - Remove the AddOnUnregisterSession cleanup hook from serve.go - Rewrite TestVMCPServer_PassthroughHeaders: the second assertion now checks that the CHANGED header value reaches the backend (per-request semantics), not that the original value is frozen Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
|
Good catch — agreed. f08f3d0 drops the |
PR size has been reduced below the XL threshold. Thank you for splitting this up!
|
✅ PR size has been reduced below the XL threshold. The size review has been dismissed and this PR can now proceed with normal review. Thank you for splitting this up! |
|
|
Addresses #5561 review comment: - MEDIUM pkg/vmcp/client/client.go (3453472675): the comment described coreToolHandler replacing per-request headers with a session-stable snapshot — that code was removed in f08f3d0. Replace with accurate description: the factory reads live per-request forwarded headers on every backend call, so forwarding is per-request. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
tgrunnagle
left a comment
There was a problem hiding this comment.
Multi-Agent Consensus Review
Agents consulted: security-reviewer, test-coverage-reviewer, general-quality-reviewer
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| F1 | Issue #5560 AC mismatch — intentional per-request vs. session-stable | 9/10 | MEDIUM | Update issue |
| F2 | Stale client.go comment references removed session-stable machinery |
7/10 | MEDIUM | Push local fix 025ee28a |
| F3 | Stale "session-stable" comment in NewVMCPServer |
6/10 | LOW | Fix |
| F4 | Stale vmcpserver.Config type name in passthrough_headers_test.go |
7/10 | LOW | Fix |
Overall
This PR correctly identifies and fixes the actual bug: passthrough headers were silently dropped on the Serve path because defaultClientFactory built its transport from target.HeaderForward only and never read ForwardedHeadersFromContext(ctx). The fix — calling MergeForwardedHeaders inside the factory — is targeted and correct. Session-stable forwarding is not restored; the user has confirmed this was always a coincidental side-effect of the per-session connection model, not a design requirement.
The implementation is substantially simpler than the previous iteration: no sync.Map, no session-capture hooks, no injectCapturedHeaders. The integration test correctly inverts the second-call assertion to match per-request semantics. All findings from the prior review have been addressed (telemetryComposer tests added, base-mutation guard added, doc comment updated for canonicalization behavior).
The remaining items are documentation: the most important is F2 — a local commit (025ee28a) already fixes the stale client.go comment but hasn't been pushed to the PR branch yet. F3 and F4 are one-line fixes. F1 asks for the linked issue's acceptance criteria to be updated so future contributors don't treat per-request forwarding as a regression.
Generated with Claude Code
Addresses #5561 review comments: - LOW test/integration/vmcp/helpers/vmcp_server.go (3453566471): replace "session-stable" with "per-request" — the PR forwards headers per-request, not from a frozen session-creation snapshot - LOW test/integration/vmcp/passthrough_headers_test.go (3453566476): replace stale type name vmcpserver.Config.PassthroughHeaders with the correct vmcpserver.ServerConfig.PassthroughHeaders (two occurrences) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
* Route server.New through core.New and Serve 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> * Harden server.New validation and resource cleanup 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> * Address review: validation, docs, dead-code removal 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> * Cache backend aggregation per identity on the Serve path 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> * Move backend health monitor ownership into the core 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> * Back the capability cache with golang-lru, not a hand-rolled map 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> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Remove the session-factory aggregation mirror The core is the single source of capability aggregation on the Serve path (AdvertiseFromCore), so the session factory's parallel aggregation is dead code. Part of the #5621 Stage 2 dead-factory-mirror removal: - session/factory.go: remove the `aggregator` field, `WithAggregator`, `buildRoutingTableWithAggregator`, and the `if f.aggregator != nil` branch — makeBaseSession always builds the routing table from raw backend capabilities (no overrides/conflict- resolution/filter; the core owns that). makeBaseSession's now-always-nil error return is dropped (+ its two callers). - aggregator: remove `Aggregator.ProcessPreQueriedCapabilities` (interface + impl + regenerated mock) — its only caller was buildRoutingTableWithAggregator. - cli/serve.go: inline the trivial createSessionFactory to NewSessionFactory and drop the WithAggregator(agg) wiring (agg still feeds the core via Config.Aggregator). Part of #5621. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Remove session-factory composite/telemetry mirrors The core owns composite-tool execution (executeComposite) and workflow telemetry (core_telemetry.go's telemetryComposer, #5561), and nothing sets FactoryConfig.WorkflowDefs/ ComposerFactory on the Serve path (server.New and the integration harness leave them unset), so the session factory's composite-tool decorator and its workflow-executor telemetry never run. Remove them — the second half of #5621 Stage 2: - sessionmanager/factory.go: delete compositeToolsDecorator, composerWorkflowExecutor, workflowExecutorInstruments + newWorkflowExecutorInstruments + wrapExecutor, and telemetryWorkflowExecutor; drop the instruments param from buildDecoratingFactory. The optimizer decorator and its telemetry (monitorOptimizer/telemetryOptimizer) are untouched. - FactoryConfig: drop the dead WorkflowDefs + ComposerFactory fields. - session_manager.go: drop the workflow-instruments setup and the now-dead "ComposerFactory required when WorkflowDefs set" validation. Part of #5621. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
The vMCP `Serve` path (introduced in #5556) routes all tool and resource calls through a shared backend client (`pkg/vmcp/client`). That client built its outgoing transport from `target.HeaderForward` only and never read `ForwardedHeadersFromContext(ctx)`, so allowlisted passthrough headers (e.g. `X-Api-Key`) were silently dropped on every backend call — a regression from the legacy `server.New` path.
Closes #5560
Type of change
Test plan
`TestVMCPServer_PassthroughHeaders` is re-enabled and verifies end-to-end that an allowlisted header reaches the backend and a non-allowlisted header is dropped. The second call asserts that a mid-request header change is reflected on the next call (per-request forwarding). New unit tests cover `MergeForwardedHeaders` and `telemetryComposer`.
Changes
Generated with Claude Code