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