Restore session-stable passthrough-header forwarding on the Serve path#5561
Restore session-stable passthrough-header forwarding on the Serve path#5561tgrunnagle wants to merge 4 commits into
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.95% -0.11%
==========================================
Files 650 652 +2
Lines 66167 66445 +278
==========================================
+ Hits 46352 46479 +127
- Misses 16459 16594 +135
- Partials 3356 3372 +16 ☔ 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 |
Summary
The vMCP
Servepath (introduced in #5556) routes all tool and resource calls through a shared backend client rather than a per-session connection. This meant passthrough headers were read per-request from the live incoming context, so a mid-session header change would reach the backend — a behavioral regression versus the legacyserver.Newpath and a security concern when the forwarded headers are credentials (e.g. API keys).This PR restores session-stable passthrough-header forwarding on the
Servepath by capturing the allowlisted headers once at session creation and re-injecting that snapshot into every subsequent backend call for that session's lifetime. The captured state is held node-local in async.Mapand is never written to Redis or any shared state store.Closes #5560
handleSessionRegistrationImplreadsheaderforward.ForwardedHeadersFromContextonce and stores the result inServer.capturedPassthroughHeaders(keyed by session ID).coreToolHandlerandcoreResourceHandlercall the newinjectCapturedHeadershelper before delegating tocore.CallTool/core.ReadResource, replacing the per-request context value with the session-stable snapshot.pkg/vmcp/clientnow callsheaderforward.MergeForwardedHeadersto combine the session-captured headers with the static per-backendHeaderForwardConfigbefore building the transport chain.MergeForwardedHeaderspromoted: The merge logic that previously lived only inpkg/vmcp/session/internal/backendis now the exportedheaderforward.MergeForwardedHeaders, shared by both the legacy per-session connector and the new shared client path.OnUnregisterSessionhook: A new SDK hook deletes the node-local entry when a session is unregistered (both explicit client DELETE and idle-TTL expiry).Servepath:core.Newnow wraps the workflow engine with OTEL metrics (toolhive_vmcp_workflow_*) when aTelemetryProvideris present, matching the instrumentation already present on the session-factory path.test/integration/vmcp/helpers/vmcp_server.gois updated to usecore.New+Serve(the production path) instead of the legacyserver.New, enablingTestVMCPServer_PassthroughHeadersto exercise the session-stable guarantee.Type of change
Test plan
task test)task lint-fix)New unit tests in
pkg/vmcp/headerforward/transport_test.gocoverMergeForwardedHeaders: empty-forwarded no-op, static-only, forwarded-only, merged output, header canonicalization, restricted-name silently dropped, and collision detection. TheTestVMCPServer_PassthroughHeadersintegration test (previously skipped with a reference to this issue) is re-enabled.Changes
pkg/vmcp/headerforward/transport.goMergeForwardedHeaders(moved frommcp_session.go)pkg/vmcp/headerforward/transport_test.goMergeForwardedHeaderspkg/vmcp/server/server.gocapturedPassthroughHeaders sync.Mapfield; capture headers inhandleSessionRegistrationImplpkg/vmcp/server/serve.goOnUnregisterSessionhook to clean up node-local header statepkg/vmcp/server/serve_handlers.goinjectCapturedHeadersincoreToolHandlerandcoreResourceHandler; addinjectCapturedHeadershelperpkg/vmcp/client/client.goMergeForwardedHeadersbefore building transport chainpkg/vmcp/core/core_vmcp.goServepath viatelemetryComposerpkg/vmcp/core/core_telemetry.gotelemetryComposerandworkflowInstrumentspkg/vmcp/session/internal/backend/mcp_session.gomergeForwardedHeadersbody with a call toheaderforward.MergeForwardedHeaderspkg/vmcp/session/internal/backend/mcp_session_test.gotransport_test.go)test/integration/vmcp/helpers/vmcp_server.goserver.Newtocore.New+ServeDoes this introduce a user-facing change?
No. The fix restores behavior that was already guaranteed by the legacy
server.Newpath. From a user's perspective, passthrough headers (e.g.X-Api-Key) forwarded to backends continue to work, and the security property that a mid-session header change does not reach the backend is preserved.Special notes for reviewers
Cross-pod behavior: A session restored on another pod will have no captured headers (the
sync.Mapis node-local). This matches the legacy path's behavior — the legacy per-session connection also could not be serialized across pods. Persisting the captured headers to Redis would require storing credentials in shared state, which is explicitly forbidden by the security constraint in.claude/rules/security.md.Abandoned sessions (client crashes,
sessionIdleTTL=0): Their entries persist incapturedPassthroughHeadersuntil server shutdown. This is acceptable because the map is bounded by the number of live sessions and each entry is small. The SDK's idle-TTL sweeper firesOnUnregisterSessionfor expired sessions whensessionIdleTTL > 0.Generated with Claude Code