Skip to content

Forward passthrough headers on the Serve path#5561

Merged
tgrunnagle merged 7 commits into
mainfrom
vmcp-core_issue_5560
Jun 22, 2026
Merged

Forward passthrough headers on the Serve path#5561
tgrunnagle merged 7 commits into
mainfrom
vmcp-core_issue_5560

Conversation

@tgrunnagle

@tgrunnagle tgrunnagle commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

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

  • Promote `MergeForwardedHeaders`: The merge logic that previously existed only inside `pkg/vmcp/session/internal/backend` (legacy per-session connector) is now the exported `headerforward.MergeForwardedHeaders`. It combines the static per-backend `HeaderForwardConfig` with the per-request forwarded headers from `ForwardedHeadersFromContext(ctx)`, applying the same restricted-header checks and collision detection as the legacy path.
  • Wire it into the shared client: `pkg/vmcp/client.defaultClientFactory` now calls `MergeForwardedHeaders` before building the transport chain. `CaptureMiddleware` puts the current request's allowlisted headers into the context; the client reads them per-call, so each backend request carries the latest forwarded-header value.
  • Telemetry on the `Serve` path: `core.New` now wraps the workflow engine with OTEL metrics (`toolhive_vmcp_workflow_*`) when a `TelemetryProvider` is present, matching the instrumentation on the session-factory path.
  • Integration test helper migrated: `test/integration/vmcp/helpers/vmcp_server.go` uses `core.New` + `Serve` (the production path) instead of the legacy `server.New`.

Type of change

  • Bug fix

Test plan

  • Unit tests (`task test`)
  • Linting (`task lint-fix`)

`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

File Change
`pkg/vmcp/headerforward/transport.go` Export `MergeForwardedHeaders` (promoted from `mcp_session.go`)
`pkg/vmcp/headerforward/transport_test.go` Unit tests for `MergeForwardedHeaders`
`pkg/vmcp/client/client.go` Call `MergeForwardedHeaders` before building the transport chain
`pkg/vmcp/core/core_vmcp.go` Wire workflow telemetry via `telemetryComposer` when `TelemetryProvider` is set
`pkg/vmcp/core/core_telemetry.go` New: `telemetryComposer` wrapper + `workflowInstruments`
`pkg/vmcp/core/core_telemetry_test.go` Unit tests for `telemetryComposer`
`pkg/vmcp/session/internal/backend/mcp_session.go` Replace local `mergeForwardedHeaders` body with a call to the exported function
`pkg/vmcp/session/internal/backend/mcp_session_test.go` Remove duplicated merge tests (now in `transport_test.go`)
`test/integration/vmcp/helpers/vmcp_server.go` Migrate test helper from `server.New` to `core.New` + `Serve`
`test/integration/vmcp/passthrough_headers_test.go` Re-enable; assert per-request forwarding

Generated with Claude Code

tgrunnagle and others added 2 commits June 18, 2026 10:05
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>
@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Jun 18, 2026
@codecov

codecov Bot commented Jun 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.59829% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.93%. Comparing base (4470503) to head (e7adb86).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
pkg/vmcp/core/core_telemetry.go 88.46% 3 Missing and 3 partials ⚠️
pkg/vmcp/core/core_vmcp.go 62.50% 2 Missing and 1 partial ⚠️
pkg/vmcp/client/client.go 66.66% 1 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tgrunnagle tgrunnagle left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread pkg/vmcp/server/server.go Outdated
Comment thread pkg/vmcp/server/serve_handlers.go Outdated
Comment thread pkg/vmcp/server/server.go
Comment thread pkg/vmcp/headerforward/transport_test.go
Comment thread pkg/vmcp/headerforward/transport.go
Comment thread pkg/vmcp/server/serve_handlers.go Outdated
Comment thread pkg/vmcp/server/serve.go Outdated
Comment thread pkg/vmcp/core/core_telemetry.go
tgrunnagle and others added 2 commits June 18, 2026 11:13
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>
@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/L Large PR: 600-999 lines changed labels Jun 21, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 transformation

Alternative:

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.

@jerm-dro

Copy link
Copy Markdown
Contributor

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, defaultClientFactory built the tripper from target.HeaderForward only and never read ForwardedHeadersFromContext(ctx) — so passthrough headers were dropped entirely. The MergeForwardedHeaders call fixes that, and since the factory runs per-call, it forwards them live on every request.

The rest isn't needed: the capture/sync.Map/cleanup machinery exists to freeze the first request's header value for the session. But "session-stable" was a side effect of the legacy persistent connection, not a designed guarantee — #5466 describes the feature as per-request, and session-stability simply isn't a requirement.

Proposal: keep the client.go merge, drop the capture machinery. TestVMCPServer_PassthroughHeaders encodes the freeze, so rewrite it to assert per-request forwarding.

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>
@tgrunnagle

Copy link
Copy Markdown
Contributor Author

Good catch — agreed. f08f3d0 drops the capturedPassthroughHeaders sync.Map, injectCapturedHeaders, and the AddOnUnregisterSession hook. The MergeForwardedHeaders call in client.go already reads ForwardedHeadersFromContext(ctx) per-call, so per-request forwarding is natural. Rewrote TestVMCPServer_PassthroughHeaders to assert the changed value reaches the backend on the second call.

@tgrunnagle tgrunnagle marked this pull request as ready for review June 22, 2026 15:01
@github-actions github-actions Bot dismissed their stale review June 22, 2026 15:01

PR size has been reduced below the XL threshold. Thank you for splitting this up!

@github-actions

Copy link
Copy Markdown
Contributor

✅ 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!

@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Jun 22, 2026
@tgrunnagle tgrunnagle changed the title Restore session-stable passthrough-header forwarding on the Serve path Forward passthrough headers on the Serve path Jun 22, 2026
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jun 22, 2026
jerm-dro
jerm-dro previously approved these changes Jun 22, 2026
Comment thread pkg/vmcp/client/client.go Outdated
@jerm-dro

Copy link
Copy Markdown
Contributor

blocker: The title and description still describe the abandoned capture/sync.Map/OnUnregisterSession design — they say "session-stable" forwarding, but f08f3d0 switched to per-request (the opposite semantic). Since this squash-merges, the commit and any derived changelog would misdescribe the change. Please update the title and body to match the per-request implementation before/with the follow-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 tgrunnagle left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread pkg/vmcp/client/client.go Outdated
Comment thread test/integration/vmcp/helpers/vmcp_server.go
Comment thread test/integration/vmcp/passthrough_headers_test.go
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>
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jun 22, 2026
@tgrunnagle tgrunnagle merged commit cbaa26b into main Jun 22, 2026
45 checks passed
@tgrunnagle tgrunnagle deleted the vmcp-core_issue_5560 branch June 22, 2026 16:01
tgrunnagle added a commit that referenced this pull request Jun 22, 2026
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>
tgrunnagle added a commit that referenced this pull request Jun 22, 2026
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>
tgrunnagle added a commit that referenced this pull request Jun 22, 2026
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>
tgrunnagle added a commit that referenced this pull request Jun 23, 2026
* 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>
@github-actions github-actions Bot mentioned this pull request Jun 23, 2026
2 tasks
tgrunnagle added a commit that referenced this pull request Jun 24, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restore session-stable passthrough-header forwarding on the Serve path

2 participants