diff --git a/client/transport/oauth.go b/client/transport/oauth.go index 53f4ee2ec..e8b49027d 100644 --- a/client/transport/oauth.go +++ b/client/transport/oauth.go @@ -32,6 +32,10 @@ type OAuthConfig struct { // AuthServerMetadataURL is the URL to the OAuth server metadata // If empty, the client will attempt to discover it from the base URL AuthServerMetadataURL string + // ProtectedResourceMetadataURL is an explicit URL for the OAuth Protected + // Resource metadata endpoint. If set, it is used directly instead of + // constructing the well-known URL from the base URL. + ProtectedResourceMetadataURL string // PKCEEnabled enables PKCE for the OAuth flow (recommended for public clients) PKCEEnabled bool // HTTPClient is an optional HTTP client to use for requests. @@ -145,7 +149,13 @@ type OAuthHandler struct { metadataOnce sync.Once baseURL string - mu sync.RWMutex // Protects expectedState + // protectedResourceMetadataURL is discovered at runtime from the + // WWW-Authenticate header (RFC 9728 Section 5.1). It is separate from + // config.ProtectedResourceMetadataURL which is set explicitly by the caller. + // Protected by mu. + protectedResourceMetadataURL string + + mu sync.RWMutex // Protects expectedState and protectedResourceMetadataURL expectedState string // Expected state value for CSRF protection } @@ -310,6 +320,61 @@ func (h *OAuthHandler) SetBaseURL(baseURL string) { h.baseURL = baseURL } +// SetProtectedResourceMetadataURL stores a PRM URL discovered at runtime +// (e.g. from the WWW-Authenticate header in a 401 response per RFC 9728). +func (h *OAuthHandler) SetProtectedResourceMetadataURL(metadataURL string) { + h.mu.Lock() + defer h.mu.Unlock() + h.protectedResourceMetadataURL = metadataURL +} + +// buildWellKnownURL constructs a well-known URL per RFC 8414 Section 3. +// It inserts the well-known suffix between the host and the path: +// +// https://example.com/path + "oauth-protected-resource" +// → https://example.com/.well-known/oauth-protected-resource/path +func buildWellKnownURL(baseURL, wellKnownSuffix string) (string, error) { + parsed, err := url.Parse(baseURL) + if err != nil { + return "", fmt.Errorf("failed to parse base URL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid base URL: missing scheme or host in %q", baseURL) + } + + path := strings.TrimRight(parsed.Path, "/") + wellKnown := "/.well-known/" + wellKnownSuffix + if path != "" { + wellKnown += path + } + + parsed.Path = wellKnown + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + +// extractResourceMetadataURL parses the resource_metadata parameter from a +// WWW-Authenticate header value per RFC 9728 Section 5.1. +// Returns the URL or an empty string if not found. +func extractResourceMetadataURL(wwwAuthenticate string) string { + if wwwAuthenticate == "" { + return "" + } + // RFC 7235 treats auth-param names as case-insensitive + const param = `resource_metadata="` + _, after, found := strings.Cut(strings.ToLower(wwwAuthenticate), param) + if !found { + return "" + } + // Extract the value from the original string to preserve URL casing + value, _, found := strings.Cut(wwwAuthenticate[len(wwwAuthenticate)-len(after):], `"`) + if !found { + return "" + } + return value +} + // GetExpectedState returns the expected state value (for testing purposes) func (h *OAuthHandler) GetExpectedState() string { h.mu.RLock() @@ -372,8 +437,25 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada return } + // Determine the protected resource metadata URL. Priority: + // 1. Explicit config value + // 2. Runtime-discovered value (from WWW-Authenticate header) + // 3. Construct from base URL per RFC 8414 + protectedResourceURL := h.config.ProtectedResourceMetadataURL + if protectedResourceURL == "" { + h.mu.RLock() + protectedResourceURL = h.protectedResourceMetadataURL + h.mu.RUnlock() + } + if protectedResourceURL == "" { + protectedResourceURL, err = buildWellKnownURL(baseURL, "oauth-protected-resource") + if err != nil { + h.metadataFetchErr = fmt.Errorf("failed to build protected resource URL: %w", err) + return + } + } + // Try to fetch the OAuth Protected Resource metadata - protectedResourceURL := baseURL + "/.well-known/oauth-protected-resource" req, err := http.NewRequestWithContext(ctx, http.MethodGet, protectedResourceURL, nil) if err != nil { h.metadataFetchErr = fmt.Errorf("failed to create protected resource request: %w", err) @@ -392,14 +474,19 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada // If we can't get the protected resource metadata, try OAuth Authorization Server discovery if resp.StatusCode != http.StatusOK { - h.fetchMetadataFromURL(ctx, baseURL+"/.well-known/oauth-authorization-server") + authServerWellKnown, wkErr := buildWellKnownURL(baseURL, "oauth-authorization-server") + if wkErr != nil { + h.metadataFetchErr = fmt.Errorf("failed to build auth server URL: %w", wkErr) + return + } + h.fetchMetadataFromURL(ctx, authServerWellKnown) if h.serverMetadata != nil { return } // If that also fails, fall back to default endpoints - metadata, err := h.getDefaultEndpoints(baseURL) - if err != nil { - h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", err) + metadata, defErr := h.getDefaultEndpoints(baseURL) + if defErr != nil { + h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", defErr) return } h.serverMetadata = metadata @@ -415,9 +502,9 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada // If no authorization servers are specified, fall back to default endpoints if len(protectedResource.AuthorizationServers) == 0 { - metadata, err := h.getDefaultEndpoints(baseURL) - if err != nil { - h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", err) + metadata, defErr := h.getDefaultEndpoints(baseURL) + if defErr != nil { + h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", defErr) return } h.serverMetadata = metadata @@ -427,22 +514,32 @@ func (h *OAuthHandler) getServerMetadata(ctx context.Context) (*AuthServerMetada // Use the first authorization server authServerURL := protectedResource.AuthorizationServers[0] - // Try OAuth Authorization Server Metadata first - h.fetchMetadataFromURL(ctx, authServerURL+"/.well-known/oauth-authorization-server") + // Try OAuth Authorization Server Metadata first (RFC 8414) + authServerWellKnown, wkErr := buildWellKnownURL(authServerURL, "oauth-authorization-server") + if wkErr != nil { + h.metadataFetchErr = fmt.Errorf("failed to build auth server well-known URL: %w", wkErr) + return + } + h.fetchMetadataFromURL(ctx, authServerWellKnown) if h.serverMetadata != nil { return } // If OAuth Authorization Server Metadata discovery fails, try OpenID Connect discovery - h.fetchMetadataFromURL(ctx, authServerURL+"/.well-known/openid-configuration") + oidcWellKnown, wkErr := buildWellKnownURL(authServerURL, "openid-configuration") + if wkErr != nil { + h.metadataFetchErr = fmt.Errorf("failed to build OIDC well-known URL: %w", wkErr) + return + } + h.fetchMetadataFromURL(ctx, oidcWellKnown) if h.serverMetadata != nil { return } // If both discovery methods fail, use default endpoints based on the authorization server URL - metadata, err := h.getDefaultEndpoints(authServerURL) - if err != nil { - h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", err) + metadata, defErr := h.getDefaultEndpoints(authServerURL) + if defErr != nil { + h.metadataFetchErr = fmt.Errorf("failed to get default endpoints: %w", defErr) return } h.serverMetadata = metadata diff --git a/client/transport/oauth_test.go b/client/transport/oauth_test.go index 74a63d0b2..a7fc97d7c 100644 --- a/client/transport/oauth_test.go +++ b/client/transport/oauth_test.go @@ -1381,3 +1381,226 @@ func TestOAuthHandler_RefreshToken_ProperHTTP400Error(t *testing.T) { _, getErr := tokenStore.GetToken(ctx) assert.ErrorIs(t, getErr, ErrNoToken, "No token should be saved after HTTP 400 error") } + +func TestBuildWellKnownURL(t *testing.T) { + tests := []struct { + name string + baseURL string + suffix string + want string + wantErr bool + }{ + { + name: "no path", + baseURL: "https://example.com", + suffix: "oauth-protected-resource", + want: "https://example.com/.well-known/oauth-protected-resource", + }, + { + name: "with single path segment", + baseURL: "https://server.smithery.ai/googledrive", + suffix: "oauth-protected-resource", + want: "https://server.smithery.ai/.well-known/oauth-protected-resource/googledrive", + }, + { + name: "trailing slash normalized", + baseURL: "https://example.com/path/", + suffix: "oauth-protected-resource", + want: "https://example.com/.well-known/oauth-protected-resource/path", + }, + { + name: "deep path", + baseURL: "https://example.com/a/b/c", + suffix: "oauth-authorization-server", + want: "https://example.com/.well-known/oauth-authorization-server/a/b/c", + }, + { + name: "openid-configuration suffix", + baseURL: "https://auth.example.com/tenant1", + suffix: "openid-configuration", + want: "https://auth.example.com/.well-known/openid-configuration/tenant1", + }, + { + name: "no path with auth server suffix", + baseURL: "https://auth.example.com", + suffix: "oauth-authorization-server", + want: "https://auth.example.com/.well-known/oauth-authorization-server", + }, + { + name: "missing scheme", + baseURL: "example.com/path", + suffix: "oauth-protected-resource", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := buildWellKnownURL(tt.baseURL, tt.suffix) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestExtractResourceMetadataURL(t *testing.T) { + tests := []struct { + name string + wwwAuthenticate string + want string + }{ + { + name: "standard header", + wwwAuthenticate: `Bearer resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource"`, + want: "https://resource.example.com/.well-known/oauth-protected-resource", + }, + { + name: "multiple parameters", + wwwAuthenticate: `Bearer realm="example", resource_metadata="https://rs.example.com/.well-known/oauth-protected-resource", error="insufficient_scope"`, + want: "https://rs.example.com/.well-known/oauth-protected-resource", + }, + { + name: "no resource_metadata", + wwwAuthenticate: `Bearer realm="example", error="invalid_token"`, + want: "", + }, + { + name: "empty header", + wwwAuthenticate: "", + want: "", + }, + { + name: "malformed - no closing quote", + wwwAuthenticate: `Bearer resource_metadata="https://example.com`, + want: "", + }, + { + name: "mixed case param name", + wwwAuthenticate: `Bearer Resource_Metadata="https://resource.example.com/.well-known/oauth-protected-resource"`, + want: "https://resource.example.com/.well-known/oauth-protected-resource", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractResourceMetadataURL(tt.wwwAuthenticate) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestGetServerMetadata_WithPathBasedURL(t *testing.T) { + // Mock server that serves PRM at the RFC 8414 path-inserted URL + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/oauth-protected-resource/googledrive": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OAuthProtectedResource{ + Resource: server.URL + "/googledrive", + AuthorizationServers: []string{server.URL}, + }) + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + RegistrationEndpoint: server.URL + "/register", + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + handler := NewOAuthHandler(OAuthConfig{ + ClientID: "test-client", + TokenStore: NewMemoryTokenStore(), + }) + handler.SetBaseURL(server.URL + "/googledrive") + + metadata, err := handler.GetServerMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, server.URL, metadata.Issuer) + assert.Equal(t, server.URL+"/authorize", metadata.AuthorizationEndpoint) + assert.Equal(t, server.URL+"/token", metadata.TokenEndpoint) +} + +func TestGetServerMetadata_WithProtectedResourceMetadataURL(t *testing.T) { + // Server that serves PRM at a custom URL + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/custom/prm": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OAuthProtectedResource{ + Resource: server.URL + "/api", + AuthorizationServers: []string{server.URL}, + }) + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + handler := NewOAuthHandler(OAuthConfig{ + ClientID: "test-client", + TokenStore: NewMemoryTokenStore(), + ProtectedResourceMetadataURL: server.URL + "/custom/prm", + }) + handler.SetBaseURL(server.URL + "/api") + + metadata, err := handler.GetServerMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, server.URL, metadata.Issuer) + assert.Equal(t, server.URL+"/authorize", metadata.AuthorizationEndpoint) +} + +func TestGetServerMetadata_WithDiscoveredMetadataURL(t *testing.T) { + // Server that serves PRM at a runtime-discovered URL + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/discovered/prm": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(OAuthProtectedResource{ + Resource: server.URL + "/api", + AuthorizationServers: []string{server.URL}, + }) + case "/.well-known/oauth-authorization-server": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(AuthServerMetadata{ + Issuer: server.URL, + AuthorizationEndpoint: server.URL + "/authorize", + TokenEndpoint: server.URL + "/token", + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + handler := NewOAuthHandler(OAuthConfig{ + ClientID: "test-client", + TokenStore: NewMemoryTokenStore(), + }) + handler.SetBaseURL(server.URL + "/api") + handler.SetProtectedResourceMetadataURL(server.URL + "/discovered/prm") + + metadata, err := handler.GetServerMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, server.URL, metadata.Issuer) + assert.Equal(t, server.URL+"/authorize", metadata.AuthorizationEndpoint) +} diff --git a/client/transport/sse.go b/client/transport/sse.go index 717898044..d13df19d3 100644 --- a/client/transport/sse.go +++ b/client/transport/sse.go @@ -113,8 +113,9 @@ func NewSSE(baseURL string, options ...ClientOption) (*SSE, error) { // If OAuth is configured, set the base URL for metadata discovery if smc.oauthHandler != nil { - // Extract base URL from server URL for metadata discovery - baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + // Include path so that well-known URLs are constructed correctly + // per RFC 8414 for servers deployed at sub-paths. + baseURL := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, strings.TrimRight(parsedURL.Path, "/")) smc.oauthHandler.SetBaseURL(baseURL) } @@ -180,6 +181,9 @@ func (c *SSE) Start(ctx context.Context) error { // Handle unauthorized error if resp.StatusCode == http.StatusUnauthorized { if c.oauthHandler != nil { + if metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")); metadataURL != "" { + c.oauthHandler.SetProtectedResourceMetadataURL(metadataURL) + } return &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, } @@ -457,6 +461,9 @@ func (c *SSE) SendRequest( // Handle unauthorized error if resp.StatusCode == http.StatusUnauthorized { if c.oauthHandler != nil { + if metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")); metadataURL != "" { + c.oauthHandler.SetProtectedResourceMetadataURL(metadataURL) + } return nil, &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, } @@ -605,6 +612,9 @@ func (c *SSE) SendNotification(ctx context.Context, notification mcp.JSONRPCNoti // Handle unauthorized error if resp.StatusCode == http.StatusUnauthorized { if c.oauthHandler != nil { + if metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")); metadataURL != "" { + c.oauthHandler.SetProtectedResourceMetadataURL(metadataURL) + } return &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, } diff --git a/client/transport/sse_oauth_test.go b/client/transport/sse_oauth_test.go index f95c9a9ff..83c8cb984 100644 --- a/client/transport/sse_oauth_test.go +++ b/client/transport/sse_oauth_test.go @@ -239,3 +239,62 @@ func TestSSE_IsOAuthEnabled(t *testing.T) { t.Errorf("Expected IsOAuthEnabled() to return true") } } + +func TestSSE_401_WWWAuthenticate_ExtractsMetadataURL(t *testing.T) { + metadataURL := "https://resource.example.com/.well-known/oauth-protected-resource" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+metadataURL+`"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + tokenStore := NewMemoryTokenStore() + _ = tokenStore.SaveToken(context.Background(), &Token{ + AccessToken: "stale-token", + TokenType: "Bearer", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + + transport, err := NewSSE(server.URL, WithOAuth(OAuthConfig{ + ClientID: "test-client", + TokenStore: tokenStore, + })) + if err != nil { + t.Fatalf("Failed to create SSE: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = transport.Start(ctx) + + var oauthErr *OAuthAuthorizationRequiredError + if !errors.As(err, &oauthErr) { + t.Fatalf("Expected OAuthAuthorizationRequiredError, got %T: %v", err, err) + } + + if oauthErr.Handler.protectedResourceMetadataURL != metadataURL { + t.Errorf("Expected protectedResourceMetadataURL %q, got %q", + metadataURL, oauthErr.Handler.protectedResourceMetadataURL) + } +} + +func TestSSE_BaseURL_IncludesPath(t *testing.T) { + transport, err := NewSSE( + "https://server.smithery.ai/googledrive", + WithOAuth(OAuthConfig{ClientID: "test"}), + ) + if err != nil { + t.Fatalf("Failed to create SSE: %v", err) + } + + handler := transport.GetOAuthHandler() + if handler == nil { + t.Fatal("Expected OAuth handler to be set") + } + + expected := "https://server.smithery.ai/googledrive" + if handler.baseURL != expected { + t.Errorf("Expected base URL %q, got %q", expected, handler.baseURL) + } +} diff --git a/client/transport/streamable_http.go b/client/transport/streamable_http.go index 5a6a0f888..a1a177074 100644 --- a/client/transport/streamable_http.go +++ b/client/transport/streamable_http.go @@ -162,8 +162,9 @@ func NewStreamableHTTP(serverURL string, options ...StreamableHTTPCOption) (*Str // If OAuth is configured, set the base URL for metadata discovery if smc.oauthHandler != nil { - // Extract base URL from server URL for metadata discovery - baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + // Include path so that well-known URLs are constructed correctly + // per RFC 8414 for servers deployed at sub-paths. + baseURL := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, strings.TrimRight(parsedURL.Path, "/")) smc.oauthHandler.SetBaseURL(baseURL) } @@ -297,6 +298,9 @@ func (c *StreamableHTTP) SendRequest( // Handle unauthorized error if resp.StatusCode == http.StatusUnauthorized { if c.oauthHandler != nil { + if metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")); metadataURL != "" { + c.oauthHandler.SetProtectedResourceMetadataURL(metadataURL) + } return nil, &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, } @@ -577,6 +581,9 @@ func (c *StreamableHTTP) SendNotification(ctx context.Context, notification mcp. // Handle unauthorized error if resp.StatusCode == http.StatusUnauthorized { if c.oauthHandler != nil { + if metadataURL := extractResourceMetadataURL(resp.Header.Get("WWW-Authenticate")); metadataURL != "" { + c.oauthHandler.SetProtectedResourceMetadataURL(metadataURL) + } return &OAuthAuthorizationRequiredError{ Handler: c.oauthHandler, } diff --git a/client/transport/streamable_http_oauth_test.go b/client/transport/streamable_http_oauth_test.go index adf29ba66..5141d6523 100644 --- a/client/transport/streamable_http_oauth_test.go +++ b/client/transport/streamable_http_oauth_test.go @@ -218,3 +218,64 @@ func TestStreamableHTTP_IsOAuthEnabled(t *testing.T) { t.Errorf("Expected IsOAuthEnabled() to return true") } } + +func TestStreamableHTTP_401_WWWAuthenticate_ExtractsMetadataURL(t *testing.T) { + metadataURL := "https://resource.example.com/.well-known/oauth-protected-resource" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("WWW-Authenticate", `Bearer resource_metadata="`+metadataURL+`"`) + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + tokenStore := NewMemoryTokenStore() + _ = tokenStore.SaveToken(context.Background(), &Token{ + AccessToken: "stale-token", + TokenType: "Bearer", + ExpiresAt: time.Now().Add(1 * time.Hour), + }) + + transport, err := NewStreamableHTTP(server.URL, WithHTTPOAuth(OAuthConfig{ + ClientID: "test-client", + TokenStore: tokenStore, + })) + if err != nil { + t.Fatalf("Failed to create transport: %v", err) + } + + _, err = transport.SendRequest(context.Background(), JSONRPCRequest{ + JSONRPC: "2.0", + ID: mcp.NewRequestId(1), + Method: "test", + }) + + var oauthErr *OAuthAuthorizationRequiredError + if !errors.As(err, &oauthErr) { + t.Fatalf("Expected OAuthAuthorizationRequiredError, got %T: %v", err, err) + } + + if oauthErr.Handler.protectedResourceMetadataURL != metadataURL { + t.Errorf("Expected protectedResourceMetadataURL %q, got %q", + metadataURL, oauthErr.Handler.protectedResourceMetadataURL) + } +} + +func TestStreamableHTTP_BaseURL_IncludesPath(t *testing.T) { + transport, err := NewStreamableHTTP( + "https://server.smithery.ai/googledrive", + WithHTTPOAuth(OAuthConfig{ClientID: "test"}), + ) + if err != nil { + t.Fatalf("Failed to create transport: %v", err) + } + + handler := transport.GetOAuthHandler() + if handler == nil { + t.Fatal("Expected OAuth handler to be set") + } + + expected := "https://server.smithery.ai/googledrive" + if handler.baseURL != expected { + t.Errorf("Expected base URL %q, got %q", expected, handler.baseURL) + } +}