diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index a2915fbb4..989d4e296 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -32,6 +32,7 @@ from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack +from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.auth.oauth import OAuth from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP @@ -152,7 +153,7 @@ class WSTransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" class SSETransport(ClientTransport): @@ -183,8 +184,7 @@ class SSETransport(ClientTransport): if auth == "oauth": auth = OAuth(self.url) elif isinstance(auth, str): - self.headers["Authorization"] = auth - auth = None + auth = BearerAuth(auth) self.auth = auth @contextlib.asynccontextmanager @@ -221,7 +221,7 @@ class SSETransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" class StreamableHttpTransport(ClientTransport): @@ -252,8 +252,7 @@ class StreamableHttpTransport(ClientTransport): if auth == "oauth": auth = OAuth(self.url) elif isinstance(auth, str): - self.headers["Authorization"] = auth - auth = None + auth = BearerAuth(auth) self.auth = auth @contextlib.asynccontextmanager @@ -291,7 +290,7 @@ class StreamableHttpTransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" class StdioTransport(ClientTransport): @@ -683,7 +682,7 @@ class FastMCPTransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" class MCPConfigTransport(ClientTransport): @@ -769,7 +768,7 @@ class MCPConfigTransport(ClientTransport): yield session def __repr__(self) -> str: - return f"" + return f"" @overload diff --git a/src/fastmcp/utilities/mcp_config.py b/src/fastmcp/utilities/mcp_config.py index 5a2990980..6b4b80b4e 100644 --- a/src/fastmcp/utilities/mcp_config.py +++ b/src/fastmcp/utilities/mcp_config.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import TYPE_CHECKING, Annotated, Any, Literal from urllib.parse import urlparse from pydantic import AnyUrl, Field @@ -56,6 +56,12 @@ class RemoteMCPServer(FastMCPBaseModel): url: str headers: dict[str, str] = Field(default_factory=dict) transport: Literal["streamable-http", "sse", "http"] | None = None + auth: Annotated[ + str | Literal["oauth"] | None, + Field( + description='Either a string representing a Bearer token or the literal "oauth" to use OAuth authentication.' + ), + ] = None def to_transport(self) -> StreamableHttpTransport | SSETransport: from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -66,9 +72,11 @@ class RemoteMCPServer(FastMCPBaseModel): transport = self.transport if transport == "sse": - return SSETransport(self.url, headers=self.headers) + return SSETransport(self.url, headers=self.headers, auth=self.auth) else: - return StreamableHttpTransport(self.url, headers=self.headers) + return StreamableHttpTransport( + self.url, headers=self.headers, auth=self.auth + ) class MCPConfig(FastMCPBaseModel): diff --git a/tests/client/test_client.py b/tests/client/test_client.py index e012d8336..6672157c0 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -4,9 +4,11 @@ from typing import cast import pytest from mcp import McpError +from mcp.client.auth import OAuthClientProvider from pydantic import AnyUrl from fastmcp.client import Client +from fastmcp.client.auth.bearer import BearerAuth from fastmcp.client.transports import ( FastMCPTransport, MCPConfigTransport, @@ -810,3 +812,73 @@ class TestInferTransport: server = FastMCP1() transport = infer_transport(server) assert isinstance(transport, FastMCPTransport) + + +class TestAuth: + def test_default_auth_is_none(self): + client = Client(transport=StreamableHttpTransport("http://localhost:8000")) + assert client.transport.auth is None + + def test_stdio_doesnt_support_auth(self): + with pytest.raises(ValueError, match="This transport does not support auth"): + Client(transport=StdioTransport("echo", ["hello"]), auth="oauth") + + def test_oauth_literal_sets_up_oauth_shttp(self): + client = Client( + transport=StreamableHttpTransport("http://localhost:8000"), auth="oauth" + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_oauth_literal_pass_direct_to_transport(self): + client = Client( + transport=StreamableHttpTransport("http://localhost:8000", auth="oauth"), + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_oauth_literal_sets_up_oauth_sse(self): + client = Client(transport=SSETransport("http://localhost:8000"), auth="oauth") + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_oauth_literal_pass_direct_to_transport_sse(self): + client = Client(transport=SSETransport("http://localhost:8000", auth="oauth")) + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, OAuthClientProvider) + + def test_auth_string_sets_up_bearer_auth_shttp(self): + client = Client( + transport=StreamableHttpTransport("http://localhost:8000"), + auth="test_token", + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" + + def test_auth_string_pass_direct_to_transport_shttp(self): + client = Client( + transport=StreamableHttpTransport( + "http://localhost:8000", auth="test_token" + ), + ) + assert isinstance(client.transport, StreamableHttpTransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" + + def test_auth_string_sets_up_bearer_auth_sse(self): + client = Client( + transport=SSETransport("http://localhost:8000"), + auth="test_token", + ) + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" + + def test_auth_string_pass_direct_to_transport_sse(self): + client = Client( + transport=SSETransport("http://localhost:8000", auth="test_token"), + ) + assert isinstance(client.transport, SSETransport) + assert isinstance(client.transport.auth, BearerAuth) + assert client.transport.auth.token.get_secret_value() == "test_token" diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 9ca60f0d8..39e87c8c9 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -9,7 +9,7 @@ from pydantic import AnyUrl from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport +from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport from fastmcp.exceptions import ToolError from fastmcp.server.proxy import FastMCPProxy @@ -104,7 +104,8 @@ def test_as_proxy_with_url(): """FastMCP.as_proxy should accept a URL without connecting.""" proxy = FastMCP.as_proxy("http://example.com/mcp") assert isinstance(proxy, FastMCPProxy) - assert repr(proxy.client.transport).startswith("