Generalize vMCP session dial-control into a per-workload resolver - #6567
Conversation
PR #6547 added session.WithDialControl / backend.WithDialControl: a single, address-blind net.Dialer.Control hook applied to every backend's session-init dials. Because the hook receives only (network, address, RawConn), an embedder cannot vary the dial policy per backend. Enterprise connector-gateway needs a per-backend dial policy at session init (some backends opt into private-IP dialing, most don't), which the single hook cannot express. #6547 is merged but not yet in any release tag, so this generalizes that option in place rather than adding a second one alongside — one option, not two. - Replace session.WithDialControl with WithDialControlResolver, a func(workloadID string) func(network, address string, c syscall.RawConn) error, mirroring the sibling WithRevisionLookup / WithRequestTimeoutResolver options. - Replace backend.WithDialControl (HTTPConnectorOption) likewise; the connector resolves the per-workload hook in NewHTTPConnector's closure (where the target is known) and threads the already-resolved hook through mcpClientParams, so createMCPClient and backendBaseTransport are unchanged. - Preserve the no-hook invariant exactly: a nil resolver, or a resolver that returns nil for a workload, leaves that backend's transport on http.DefaultTransport — byte-for-byte identical to the no-hook path. Required by enterprise connector-gateway (stacklok/stacklok-enterprise-platform#3313). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6567 +/- ##
==========================================
+ Coverage 78.72% 78.79% +0.07%
==========================================
Files 777 778 +1
Lines 77213 77500 +287
==========================================
+ Hits 60784 61067 +283
- Misses 16424 16428 +4
Partials 5 5 ☔ 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: concurrency-reviewer, security-reviewer, test-coverage-reviewer, general-quality-reviewer
Consensus Summary
| # | Finding | Consensus | Severity | Action |
|---|---|---|---|---|
| 1 | Doc comment doesn't warn that a workloadID-only resolver defeats the SSRF/DNS-rebinding defense | 8/10 | MEDIUM | Fix |
| 2 | RestoreSession path never tests per-backend resolver keying, only blanket deny-all | 8/10 | MEDIUM | Fix |
| 3 | No test coverage for a resolver that panics for a given workload | 7/10 | MEDIUM | Fix |
| 4 | Doc comments overstate "twin" parity with client.WithDialControl, now asymmetric | 7/10 | MEDIUM | Fix |
| 5 | Documented concurrent-invocation contract for the resolver isn't exercised by any race test | 8/10 | LOW | Fix |
| 6 | Resolver's parameter named resolve, diverging from sibling WithRequestTimeoutResolver's resolver |
7/10 | LOW | Fix |
Overall
This is a small, self-contained refactor that generalizes an unreleased, opt-in embedder option (WithDialControl) into a per-workload resolver (WithDialControlResolver), fixing a real limitation of the single address-blind hook from #6547. The approach is sound — it mirrors the two resolver-style options already on the same struct (WithRevisionLookup, WithRequestTimeoutResolver) rather than inventing a new shape, and the no-hook default path is proven byte-for-byte unchanged.
All findings below are polish and coverage gaps, not correctness bugs — nothing here should block merge. The two most worth addressing before merge: the RestoreSession path only has a blanket deny-all test (no per-backend-keying proof, unlike the MakeSessionWithID path), and the security-limitations doc doesn't warn that a resolver deciding purely on workloadID without inspecting the dialed address gives zero SSRF protection despite looking like a per-backend guard.
Documentation
mcp_session.go(mirrored infactory.go): add an explicit caveat to "Security limitations embedders must understand" that the resolver must select a hook which itself inspects the resolved address — gating purely onworkloadIDdefeats the SSRF/DNS-rebinding defense (see inline comment).- Both files'
WithDialControlResolverdoc comments: the "twin"/"counterpart" framing withclient.WithDialControlshould acknowledge that the two option shapes are now asymmetric (see inline comment).
Generated with Claude Code
Addresses #6567 review comments: - MEDIUM mcp_session.go (3970572745): document that the resolver only selects a hook; the returned hook must inspect the resolved address or it gives no SSRF/DNS-rebinding protection (mirrored in factory.go) - MEDIUM mcp_session.go (3970572769): scope the client.WithDialControl "twin"/"counterpart" framing to the returned hook's signature and note the client option is not yet per-backend (mirrored in factory.go) - LOW mcp_session.go / factory.go (3970572781): rename the option parameter resolve -> resolver to match the sibling WithRequestTimeoutResolver
Addresses #6567 review comments: - MEDIUM mcp_session.go (3970572761): recover a panicking per-workload resolver via resolveDialControl so it is isolated to that backend (excluded like any init failure) instead of crashing the per-backend init goroutine and the process; covered by a factory test proving the surviving backend still connects - MEDIUM factory_dialcontrol_test.go (3970572752): add a RestoreSession per-backend-keying test (deny one of two workloads; assert the allowed one survives restore and the denied one is excluded) - LOW factory_dialcontrol_test.go (3970572776): add a -race barrier test proving the resolver is invoked concurrently across the per-backend init goroutines
jhrozek
left a comment
There was a problem hiding this comment.
Reviewed the diff (factory.go, mcp_session.go, + tests) across Go quality, security, and ToolHive conventions/test-coverage angles.
- Clean, narrow refactor of the session-init dial-control option from a single static hook into a per-workload resolver, mirroring the existing
WithRevisionLookup/WithRequestTimeoutResolverpattern. - Panic-recovery in
resolveDialControlis fail-closed: a panicking resolver excludes that backend rather than silently leaving the hook absent. - No regression in the SSRF/DNS-rebinding guarantee; the new caveat that the resolver only selects a hook (the returned hook must itself check the address) is documented prominently.
- Test coverage is thorough: per-backend resolution on both init and restore, panic isolation, and a real barrier-based concurrency test.
- Checked the API-removal concern raised elsewhere:
WithDialControlwas added in #6547 and never shipped in a release (last tag v0.47.1 predates it) and has no in-tree caller, so the rename is not a compatibility break.
No blocking issues found.
PR #6567 generalized session.WithDialControl into session.WithDialControlResolver (per-workload). Update serve.go's session-factory wiring to the resolver shape. The policy is still global (config.BackendAllowPrivateIP), so the resolver returns the same hook for every workload; the shape leaves room for a future per-backend policy. The per-call backend client (client.WithDialControl) is unchanged.
Summary
PR #6547 added
session.WithDialControl/backend.WithDialControl: a single, address-blindnet.Dialer.Controlhook applied to every backend's session-init dials (the MCP handshake plus capability listing). Because that hook receives only(network, address, RawConn), an embedder cannot vary the dial policy per backend — every backend gets the same hook. vMCP needs a per-backend dial policy at session initialize (some backends opt into private-IP dialing, most don't), which the single hook cannot express.Since #6547 is merged but not yet in any release tag, this generalizes that option in place rather than adding a second one alongside — we ship one option, not two.
session.WithDialControlwithsession.WithDialControlResolver, afunc(workloadID string) func(network, address string, c syscall.RawConn) error, mirroring the siblingWithRevisionLookup/WithRequestTimeoutResolveroptions in the same file. The resolver is called per backend and returns the hook to install for that workload (ornil).backend.WithDialControl(HTTPConnectorOption) likewise. The connector resolves the per-workload hook inNewHTTPConnector's closure — where thetarget(and thustarget.WorkloadID) is known — and threads the already-resolved hook throughmcpClientParams, socreateMCPClientand thebackendBaseTransport/networking.CloneDefaultTransportWithDialControlprimitive are unchanged.nilfor a given workload, leaves that backend's transport onhttp.DefaultTransport— byte-for-byte identical to the no-hook path (no clone).Type of change
The default (no resolver) path is unchanged; this reshapes an opt-in embedder API that is not yet released.
Test plan
task test)task lint-fix)Ran the affected packages (
pkg/networking,pkg/vmcp/session,pkg/vmcp/session/internal/backend,pkg/vmcp/client) with the Taskfile flags — all green;golangci-lint --fixreports 0 issues. Coverage kept and extended:nilfor another — the denied workload's server is never reached (hook asserted to have fired) while the allowed workload's dial proceeds and reaches its server. At the factory level, a two-backend session whose resolver denies one workload ID proves the allowed backend connects and the denied one is excluded.initializefor both thestreamable-httpandssetransports, with the hook asserted to have actually fired.RestoreSessionpath.http.DefaultTransportunchanged (TestBackendBaseTransport_NilHookReturnsDefault, unchanged).Changes
pkg/vmcp/session/factory.goWithDialControlwithWithDialControlResolver; hold the resolver on the factory and thread it into the HTTP connector.pkg/vmcp/session/factory_dialcontrol_test.goworkloadIDinstead of address.pkg/vmcp/session/internal/backend/mcp_session.goWithDialControlwithWithDialControlResolver; resolve the per-workload hook in the connector closure and thread the resolved hook throughmcpClientParams.createMCPClientandbackendBaseTransportunchanged.pkg/vmcp/session/internal/backend/mcp_session_dialcontrol_test.goDoes this introduce a user-facing change?
No end-user behavior changes. This reshapes an opt-in, not-yet-released embedder API from a single address-blind dial-control hook into a per-workload resolver, so a deployment can enforce a per-backend dial policy on the connections opened at vMCP session initialization.
Special notes for reviewers
backendBaseTransport(nil)returnshttp.DefaultTransportdirectly rather than a clone, and the connector installs no hook when the resolver is nil or returns nil.networking.CloneDefaultTransportWithDialControlis the right primitive and is untouched — only the option shape above it changes.maxConcurrency), so it is documented as requiring concurrency-safety.cli/serve.gofollow-up was deferred), so this is an internal-plumbing + test-surface change with no call site to migrate.Generated with Claude Code