Documentation
¶
Overview ¶
Package client provides MCP protocol client implementation for communicating with backend servers.
This package implements the BackendClient interface defined in the vmcp package, using the stacklok/toolhive-core/mcpcompat SDK for protocol communication.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func NewHTTPBackendClient ¶
func NewHTTPBackendClient(registry vmcpauth.OutgoingAuthRegistry, opts ...Option) (vmcp.BackendClient, error)
NewHTTPBackendClient creates a new HTTP-based backend client. This client supports streamable-HTTP and SSE transports.
The registry parameter manages authentication strategies for outgoing requests to backend MCP servers. It must not be nil. To disable authentication, use a registry configured with the "unauthenticated" strategy.
Options are additive: nil or absent options reproduce the default behavior exactly. See WithDialControl to install a per-connection dial hook for SSRF / DNS-rebinding defense.
Returns an error if registry is nil.
Types ¶
type Option ¶ added in v0.30.1
type Option func(*httpBackendClient)
Option configures an httpBackendClient.
func WithDialControl ¶ added in v0.30.1
WithDialControl installs a per-connection Control hook on the dialer used to reach every backend. The hook fires after DNS resolution and before the TCP handshake, receiving the resolved peer IP in address — which is why it defeats DNS-rebinding attacks that a host-name–based allow/deny check cannot: a hostname can legitimately resolve to a blocked IP after the name-based check passes.
The hook composes with per-backend CA-bundle handling: it augments the internally built *http.Transport rather than replacing it. Supplying it implies the standard dial timeouts (30 s Timeout, 30 s KeepAlive) used throughout this package.
The signature matches net.Dialer.Control exactly.
Security limitations embedders must understand:
Per-TCP-dial, not per-request: the hook fires once per TCP connection. A pooled connection is reused without re-invoking the hook until it is recycled. Because each backend gets its own isolated transport and connection pool, a reused connection is always one this hook already approved on its first dial — reuse cannot reach an unclassified peer. This client does not offer per-request re-classification.
Proxy transparency: when http.ProxyFromEnvironment selects a proxy (HTTP_PROXY/HTTPS_PROXY set), the dial target is the proxy server, so the hook receives the proxy's IP, not the backend's. Embedders relying on this hook for SSRF or IP allow-listing must either unset the proxy env vars or additionally validate the request URL's host before dialing.
Both IP families: the address argument may be an IPv4 or IPv6 literal (host:port form); embedders must handle both families — including IPv4-mapped IPv6 such as ::ffff:127.0.0.1 — in their check. See the OWASP SSRF Prevention Cheat Sheet for the full set of ranges to deny (loopback, RFC 1918, link-local 169.254/16, CGNAT 100.64/10, IPv6 ULA).
Example ¶
ExampleWithDialControl shows how an embedder installs a dial-control hook that rejects connections to non-public IP ranges — the building block of an SSRF / DNS-rebinding defense. The hook receives the resolved peer IP, so it classifies the address the kernel is about to connect to rather than the (re-resolvable) hostname. A production check should additionally cover the ranges listed in the WithDialControl doc comment (CGNAT, IPv6 ULA, etc.).
denyNonPublic := func(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
ip := net.ParseIP(host)
if ip == nil {
return fmt.Errorf("unresolved dial address %q", address)
}
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() {
return fmt.Errorf("blocked connection to non-public IP %s", ip)
}
return nil
}
registry := auth.NewDefaultOutgoingAuthRegistry()
if err := registry.RegisterStrategy(
authtypes.StrategyTypeUnauthenticated, &strategies.UnauthenticatedStrategy{},
); err != nil {
panic(err)
}
backendClient, err := NewHTTPBackendClient(registry, WithDialControl(denyNonPublic))
if err != nil {
panic(err)
}
_ = backendClient
func WithRefutationTTL ¶ added in v0.42.0
WithRefutationTTL overrides how long a refuted Modern-handshake hint suppresses the confirming server/discover probe in legacyInit. Zero or negative restores the default. Production callers should not set this; it exists so tests can shrink the window.
func WithRequestTimeoutResolver ¶ added in v0.45.0
WithRequestTimeoutResolver configures the timeout used for each backend operation. The resolver receives the backend workload ID and may return a workload-specific duration. A nil resolver, or a non-positive result, uses the 30-second default.
The resolver may be called concurrently and must therefore be safe for concurrent use. SSE connection lifetimes remain unbounded, but individual operations on those connections are bounded through request contexts.