From 35c7f2845c2a180f1fc69858427f8977e1fe5dcf Mon Sep 17 00:00:00 2001 From: Jonathan Marler Date: Fri, 18 Sep 2026 07:56:01 -0600 Subject: [PATCH] oauthex: keep defaultDiscoveryTransport out of package initialization PR #1278 made the default discovery transport lazy with a package-level sync.OnceValue. The closure passed to OnceValue is still referenced from the package initializer, and initializers are always linked, so the linker keeps newDiscoveryTransport and everything it reaches: http.Transport.Clone, the HTTP/2 transport setup, and crypto/tls. The mcp package imports auth, which imports oauthex, so every program using the SDK pays for this, including stdio-only servers that never make an HTTP request. The hello example server (linux/amd64) shrinks from 11.1MB to 8.7MB with Go 1.25.0, and from 11.5MB to 9.1MB with Go 1.26.8. Make defaultDiscoveryTransport a function backed by sync.Once, the same pattern net/http uses for its proxy environment. The transport is still built once on first use, preserving the proxy behavior from #1276, but it is only linked into programs that can call it. Add a test that builds the hello example and fails if it links the TLS client handshake. Building the transport at import time would also re-link it, so the test guards #1276 as well. Updates #1276 --- mcp/linker_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ oauthex/oauth2.go | 28 +++++++++++++++++++--------- 2 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 mcp/linker_test.go diff --git a/mcp/linker_test.go b/mcp/linker_test.go new file mode 100644 index 00000000..22013ae9 --- /dev/null +++ b/mcp/linker_test.go @@ -0,0 +1,45 @@ +// Copyright 2026 The Go MCP SDK Authors. All rights reserved. +// Use of this source code is governed by the license +// that can be found in the LICENSE file. + +package mcp_test + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestStdioServerDoesNotLinkTLS builds a stdio-only server and checks that +// the linker drops the TLS client. A package-level initializer that touches +// http.Transport would pull it back in for every program importing mcp. +func TestStdioServerDoesNotLinkTLS(t *testing.T) { + goTool, err := exec.LookPath("go") + if err != nil { + t.Skip("go tool not found") + } + bin := filepath.Join(t.TempDir(), "hello") + if out, err := exec.Command(goTool, "build", "-o", bin, "../examples/server/hello").CombinedOutput(); err != nil { + t.Fatalf("go build: %v\n%s", err, out) + } + out, err := exec.Command(goTool, "tool", "nm", bin).CombinedOutput() + if err != nil { + t.Fatalf("go tool nm: %v\n%s", err, out) + } + wantSym := map[string]bool{ + // Sanity check that nm read the server binary. + "github.com/modelcontextprotocol/go-sdk/mcp.(*Server).Run": true, + // The exported Handshake is inlined away, so check its callee. + "crypto/tls.(*Conn).clientHandshake": false, + } + for sym, want := range wantSym { + got := strings.Contains(string(out), sym) + if want && !got { + t.Errorf("symbol %s not found; is this the right binary?", sym) + } + if !want && got { + t.Errorf("stdio-only server links %s; a package initializer likely reaches http.Transport (see go build -ldflags=-dumpdep)", sym) + } + } +} diff --git a/oauthex/oauth2.go b/oauthex/oauth2.go index 0e77149a..8e775d4f 100644 --- a/oauthex/oauth2.go +++ b/oauthex/oauth2.go @@ -26,15 +26,25 @@ import ( const maxDiscoveryRedirects = 10 -// Build this on first use, not when the package loads. Building it checks the -// proxy via http.ProxyFromEnvironment, and net/http only reads the proxy -// environment once and then remembers it for the whole run. If that happened -// at import time, it would lock in the proxy setting before the app had a -// chance to set HTTPS_PROXY. sync.OnceValue waits until first use and still -// builds the transport just once. See #1276. -var defaultDiscoveryTransport = sync.OnceValue(func() http.RoundTripper { - return newDiscoveryTransport(http.DefaultTransport) -}) +var ( + defaultDiscoveryTransportOnce sync.Once + defaultDiscoveryTransportValue http.RoundTripper +) + +// defaultDiscoveryTransport is built on first use, not at package init: +// building it consults http.ProxyFromEnvironment, which net/http caches for +// the whole run, so doing it at import time would freeze the proxy settings +// before main could set HTTPS_PROXY (#1276). It is a function rather than a +// package-level sync.OnceValue because a package initializer is a linker +// root: a OnceValue var kept http.Transport.Clone, and through it the HTTP/2 +// client and crypto/tls, in every program importing mcp, including stdio-only +// servers that never make an HTTP request. +func defaultDiscoveryTransport() http.RoundTripper { + defaultDiscoveryTransportOnce.Do(func() { + defaultDiscoveryTransportValue = newDiscoveryTransport(http.DefaultTransport) + }) + return defaultDiscoveryTransportValue +} type httpStatusError struct { StatusCode int