Spaces:
Running
Running
Merge pull request #754 from jlowin/mcpconfig-auth
Browse filesFix passing token string to client auth & add auth to MCPConfig clients
src/fastmcp/client/transports.py
CHANGED
|
@@ -32,6 +32,7 @@ from mcp.shared.memory import create_connected_server_and_client_session
|
|
| 32 |
from pydantic import AnyUrl
|
| 33 |
from typing_extensions import Unpack
|
| 34 |
|
|
|
|
| 35 |
from fastmcp.client.auth.oauth import OAuth
|
| 36 |
from fastmcp.server.dependencies import get_http_headers
|
| 37 |
from fastmcp.server.server import FastMCP
|
|
@@ -152,7 +153,7 @@ class WSTransport(ClientTransport):
|
|
| 152 |
yield session
|
| 153 |
|
| 154 |
def __repr__(self) -> str:
|
| 155 |
-
return f"<
|
| 156 |
|
| 157 |
|
| 158 |
class SSETransport(ClientTransport):
|
|
@@ -183,8 +184,7 @@ class SSETransport(ClientTransport):
|
|
| 183 |
if auth == "oauth":
|
| 184 |
auth = OAuth(self.url)
|
| 185 |
elif isinstance(auth, str):
|
| 186 |
-
|
| 187 |
-
auth = None
|
| 188 |
self.auth = auth
|
| 189 |
|
| 190 |
@contextlib.asynccontextmanager
|
|
@@ -221,7 +221,7 @@ class SSETransport(ClientTransport):
|
|
| 221 |
yield session
|
| 222 |
|
| 223 |
def __repr__(self) -> str:
|
| 224 |
-
return f"<
|
| 225 |
|
| 226 |
|
| 227 |
class StreamableHttpTransport(ClientTransport):
|
|
@@ -252,8 +252,7 @@ class StreamableHttpTransport(ClientTransport):
|
|
| 252 |
if auth == "oauth":
|
| 253 |
auth = OAuth(self.url)
|
| 254 |
elif isinstance(auth, str):
|
| 255 |
-
|
| 256 |
-
auth = None
|
| 257 |
self.auth = auth
|
| 258 |
|
| 259 |
@contextlib.asynccontextmanager
|
|
@@ -291,7 +290,7 @@ class StreamableHttpTransport(ClientTransport):
|
|
| 291 |
yield session
|
| 292 |
|
| 293 |
def __repr__(self) -> str:
|
| 294 |
-
return f"<
|
| 295 |
|
| 296 |
|
| 297 |
class StdioTransport(ClientTransport):
|
|
@@ -683,7 +682,7 @@ class FastMCPTransport(ClientTransport):
|
|
| 683 |
yield session
|
| 684 |
|
| 685 |
def __repr__(self) -> str:
|
| 686 |
-
return f"<
|
| 687 |
|
| 688 |
|
| 689 |
class MCPConfigTransport(ClientTransport):
|
|
@@ -769,7 +768,7 @@ class MCPConfigTransport(ClientTransport):
|
|
| 769 |
yield session
|
| 770 |
|
| 771 |
def __repr__(self) -> str:
|
| 772 |
-
return f"<
|
| 773 |
|
| 774 |
|
| 775 |
@overload
|
|
|
|
| 32 |
from pydantic import AnyUrl
|
| 33 |
from typing_extensions import Unpack
|
| 34 |
|
| 35 |
+
from fastmcp.client.auth.bearer import BearerAuth
|
| 36 |
from fastmcp.client.auth.oauth import OAuth
|
| 37 |
from fastmcp.server.dependencies import get_http_headers
|
| 38 |
from fastmcp.server.server import FastMCP
|
|
|
|
| 153 |
yield session
|
| 154 |
|
| 155 |
def __repr__(self) -> str:
|
| 156 |
+
return f"<WebSocketTransport(url='{self.url}')>"
|
| 157 |
|
| 158 |
|
| 159 |
class SSETransport(ClientTransport):
|
|
|
|
| 184 |
if auth == "oauth":
|
| 185 |
auth = OAuth(self.url)
|
| 186 |
elif isinstance(auth, str):
|
| 187 |
+
auth = BearerAuth(auth)
|
|
|
|
| 188 |
self.auth = auth
|
| 189 |
|
| 190 |
@contextlib.asynccontextmanager
|
|
|
|
| 221 |
yield session
|
| 222 |
|
| 223 |
def __repr__(self) -> str:
|
| 224 |
+
return f"<SSETransport(url='{self.url}')>"
|
| 225 |
|
| 226 |
|
| 227 |
class StreamableHttpTransport(ClientTransport):
|
|
|
|
| 252 |
if auth == "oauth":
|
| 253 |
auth = OAuth(self.url)
|
| 254 |
elif isinstance(auth, str):
|
| 255 |
+
auth = BearerAuth(auth)
|
|
|
|
| 256 |
self.auth = auth
|
| 257 |
|
| 258 |
@contextlib.asynccontextmanager
|
|
|
|
| 290 |
yield session
|
| 291 |
|
| 292 |
def __repr__(self) -> str:
|
| 293 |
+
return f"<StreamableHttpTransport(url='{self.url}')>"
|
| 294 |
|
| 295 |
|
| 296 |
class StdioTransport(ClientTransport):
|
|
|
|
| 682 |
yield session
|
| 683 |
|
| 684 |
def __repr__(self) -> str:
|
| 685 |
+
return f"<FastMCPTransport(server='{self.server.name}')>"
|
| 686 |
|
| 687 |
|
| 688 |
class MCPConfigTransport(ClientTransport):
|
|
|
|
| 768 |
yield session
|
| 769 |
|
| 770 |
def __repr__(self) -> str:
|
| 771 |
+
return f"<MCPConfigTransport(config='{self.config}')>"
|
| 772 |
|
| 773 |
|
| 774 |
@overload
|
src/fastmcp/utilities/mcp_config.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from typing import TYPE_CHECKING, Any, Literal
|
| 4 |
from urllib.parse import urlparse
|
| 5 |
|
| 6 |
from pydantic import AnyUrl, Field
|
|
@@ -56,6 +56,12 @@ class RemoteMCPServer(FastMCPBaseModel):
|
|
| 56 |
url: str
|
| 57 |
headers: dict[str, str] = Field(default_factory=dict)
|
| 58 |
transport: Literal["streamable-http", "sse", "http"] | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
| 61 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
@@ -66,9 +72,11 @@ class RemoteMCPServer(FastMCPBaseModel):
|
|
| 66 |
transport = self.transport
|
| 67 |
|
| 68 |
if transport == "sse":
|
| 69 |
-
return SSETransport(self.url, headers=self.headers)
|
| 70 |
else:
|
| 71 |
-
return StreamableHttpTransport(
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
class MCPConfig(FastMCPBaseModel):
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
| 4 |
from urllib.parse import urlparse
|
| 5 |
|
| 6 |
from pydantic import AnyUrl, Field
|
|
|
|
| 56 |
url: str
|
| 57 |
headers: dict[str, str] = Field(default_factory=dict)
|
| 58 |
transport: Literal["streamable-http", "sse", "http"] | None = None
|
| 59 |
+
auth: Annotated[
|
| 60 |
+
str | Literal["oauth"] | None,
|
| 61 |
+
Field(
|
| 62 |
+
description='Either a string representing a Bearer token or the literal "oauth" to use OAuth authentication.'
|
| 63 |
+
),
|
| 64 |
+
] = None
|
| 65 |
|
| 66 |
def to_transport(self) -> StreamableHttpTransport | SSETransport:
|
| 67 |
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
|
|
|
|
| 72 |
transport = self.transport
|
| 73 |
|
| 74 |
if transport == "sse":
|
| 75 |
+
return SSETransport(self.url, headers=self.headers, auth=self.auth)
|
| 76 |
else:
|
| 77 |
+
return StreamableHttpTransport(
|
| 78 |
+
self.url, headers=self.headers, auth=self.auth
|
| 79 |
+
)
|
| 80 |
|
| 81 |
|
| 82 |
class MCPConfig(FastMCPBaseModel):
|
tests/client/test_client.py
CHANGED
|
@@ -4,9 +4,11 @@ from typing import cast
|
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
from mcp import McpError
|
|
|
|
| 7 |
from pydantic import AnyUrl
|
| 8 |
|
| 9 |
from fastmcp.client import Client
|
|
|
|
| 10 |
from fastmcp.client.transports import (
|
| 11 |
FastMCPTransport,
|
| 12 |
MCPConfigTransport,
|
|
@@ -810,3 +812,73 @@ class TestInferTransport:
|
|
| 810 |
server = FastMCP1()
|
| 811 |
transport = infer_transport(server)
|
| 812 |
assert isinstance(transport, FastMCPTransport)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
from mcp import McpError
|
| 7 |
+
from mcp.client.auth import OAuthClientProvider
|
| 8 |
from pydantic import AnyUrl
|
| 9 |
|
| 10 |
from fastmcp.client import Client
|
| 11 |
+
from fastmcp.client.auth.bearer import BearerAuth
|
| 12 |
from fastmcp.client.transports import (
|
| 13 |
FastMCPTransport,
|
| 14 |
MCPConfigTransport,
|
|
|
|
| 812 |
server = FastMCP1()
|
| 813 |
transport = infer_transport(server)
|
| 814 |
assert isinstance(transport, FastMCPTransport)
|
| 815 |
+
|
| 816 |
+
|
| 817 |
+
class TestAuth:
|
| 818 |
+
def test_default_auth_is_none(self):
|
| 819 |
+
client = Client(transport=StreamableHttpTransport("http://localhost:8000"))
|
| 820 |
+
assert client.transport.auth is None
|
| 821 |
+
|
| 822 |
+
def test_stdio_doesnt_support_auth(self):
|
| 823 |
+
with pytest.raises(ValueError, match="This transport does not support auth"):
|
| 824 |
+
Client(transport=StdioTransport("echo", ["hello"]), auth="oauth")
|
| 825 |
+
|
| 826 |
+
def test_oauth_literal_sets_up_oauth_shttp(self):
|
| 827 |
+
client = Client(
|
| 828 |
+
transport=StreamableHttpTransport("http://localhost:8000"), auth="oauth"
|
| 829 |
+
)
|
| 830 |
+
assert isinstance(client.transport, StreamableHttpTransport)
|
| 831 |
+
assert isinstance(client.transport.auth, OAuthClientProvider)
|
| 832 |
+
|
| 833 |
+
def test_oauth_literal_pass_direct_to_transport(self):
|
| 834 |
+
client = Client(
|
| 835 |
+
transport=StreamableHttpTransport("http://localhost:8000", auth="oauth"),
|
| 836 |
+
)
|
| 837 |
+
assert isinstance(client.transport, StreamableHttpTransport)
|
| 838 |
+
assert isinstance(client.transport.auth, OAuthClientProvider)
|
| 839 |
+
|
| 840 |
+
def test_oauth_literal_sets_up_oauth_sse(self):
|
| 841 |
+
client = Client(transport=SSETransport("http://localhost:8000"), auth="oauth")
|
| 842 |
+
assert isinstance(client.transport, SSETransport)
|
| 843 |
+
assert isinstance(client.transport.auth, OAuthClientProvider)
|
| 844 |
+
|
| 845 |
+
def test_oauth_literal_pass_direct_to_transport_sse(self):
|
| 846 |
+
client = Client(transport=SSETransport("http://localhost:8000", auth="oauth"))
|
| 847 |
+
assert isinstance(client.transport, SSETransport)
|
| 848 |
+
assert isinstance(client.transport.auth, OAuthClientProvider)
|
| 849 |
+
|
| 850 |
+
def test_auth_string_sets_up_bearer_auth_shttp(self):
|
| 851 |
+
client = Client(
|
| 852 |
+
transport=StreamableHttpTransport("http://localhost:8000"),
|
| 853 |
+
auth="test_token",
|
| 854 |
+
)
|
| 855 |
+
assert isinstance(client.transport, StreamableHttpTransport)
|
| 856 |
+
assert isinstance(client.transport.auth, BearerAuth)
|
| 857 |
+
assert client.transport.auth.token.get_secret_value() == "test_token"
|
| 858 |
+
|
| 859 |
+
def test_auth_string_pass_direct_to_transport_shttp(self):
|
| 860 |
+
client = Client(
|
| 861 |
+
transport=StreamableHttpTransport(
|
| 862 |
+
"http://localhost:8000", auth="test_token"
|
| 863 |
+
),
|
| 864 |
+
)
|
| 865 |
+
assert isinstance(client.transport, StreamableHttpTransport)
|
| 866 |
+
assert isinstance(client.transport.auth, BearerAuth)
|
| 867 |
+
assert client.transport.auth.token.get_secret_value() == "test_token"
|
| 868 |
+
|
| 869 |
+
def test_auth_string_sets_up_bearer_auth_sse(self):
|
| 870 |
+
client = Client(
|
| 871 |
+
transport=SSETransport("http://localhost:8000"),
|
| 872 |
+
auth="test_token",
|
| 873 |
+
)
|
| 874 |
+
assert isinstance(client.transport, SSETransport)
|
| 875 |
+
assert isinstance(client.transport.auth, BearerAuth)
|
| 876 |
+
assert client.transport.auth.token.get_secret_value() == "test_token"
|
| 877 |
+
|
| 878 |
+
def test_auth_string_pass_direct_to_transport_sse(self):
|
| 879 |
+
client = Client(
|
| 880 |
+
transport=SSETransport("http://localhost:8000", auth="test_token"),
|
| 881 |
+
)
|
| 882 |
+
assert isinstance(client.transport, SSETransport)
|
| 883 |
+
assert isinstance(client.transport.auth, BearerAuth)
|
| 884 |
+
assert client.transport.auth.token.get_secret_value() == "test_token"
|
tests/server/test_proxy.py
CHANGED
|
@@ -9,7 +9,7 @@ from pydantic import AnyUrl
|
|
| 9 |
|
| 10 |
from fastmcp import FastMCP
|
| 11 |
from fastmcp.client import Client
|
| 12 |
-
from fastmcp.client.transports import FastMCPTransport
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
from fastmcp.server.proxy import FastMCPProxy
|
| 15 |
|
|
@@ -104,7 +104,8 @@ def test_as_proxy_with_url():
|
|
| 104 |
"""FastMCP.as_proxy should accept a URL without connecting."""
|
| 105 |
proxy = FastMCP.as_proxy("http://example.com/mcp")
|
| 106 |
assert isinstance(proxy, FastMCPProxy)
|
| 107 |
-
assert
|
|
|
|
| 108 |
|
| 109 |
|
| 110 |
class TestTools:
|
|
|
|
| 9 |
|
| 10 |
from fastmcp import FastMCP
|
| 11 |
from fastmcp.client import Client
|
| 12 |
+
from fastmcp.client.transports import FastMCPTransport, StreamableHttpTransport
|
| 13 |
from fastmcp.exceptions import ToolError
|
| 14 |
from fastmcp.server.proxy import FastMCPProxy
|
| 15 |
|
|
|
|
| 104 |
"""FastMCP.as_proxy should accept a URL without connecting."""
|
| 105 |
proxy = FastMCP.as_proxy("http://example.com/mcp")
|
| 106 |
assert isinstance(proxy, FastMCPProxy)
|
| 107 |
+
assert isinstance(proxy.client.transport, StreamableHttpTransport)
|
| 108 |
+
assert proxy.client.transport.url == "http://example.com/mcp"
|
| 109 |
|
| 110 |
|
| 111 |
class TestTools:
|
tests/utilities/test_mcp_config.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
import inspect
|
| 2 |
from pathlib import Path
|
| 3 |
|
|
|
|
|
|
|
| 4 |
from fastmcp.client.client import Client
|
| 5 |
from fastmcp.client.transports import (
|
| 6 |
SSETransport,
|
|
@@ -136,3 +138,60 @@ async def test_multi_client(tmp_path: Path):
|
|
| 136 |
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
|
| 137 |
assert result_1[0].text == "3" # type: ignore[attr-dict]
|
| 138 |
assert result_2[0].text == "3" # type: ignore[attr-dict]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import inspect
|
| 2 |
from pathlib import Path
|
| 3 |
|
| 4 |
+
from fastmcp.client.auth.bearer import BearerAuth
|
| 5 |
+
from fastmcp.client.auth.oauth import OAuthClientProvider
|
| 6 |
from fastmcp.client.client import Client
|
| 7 |
from fastmcp.client.transports import (
|
| 8 |
SSETransport,
|
|
|
|
| 138 |
result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2})
|
| 139 |
assert result_1[0].text == "3" # type: ignore[attr-dict]
|
| 140 |
assert result_2[0].text == "3" # type: ignore[attr-dict]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
async def test_remote_config_default_no_auth():
|
| 144 |
+
config = {
|
| 145 |
+
"mcpServers": {
|
| 146 |
+
"test_server": {
|
| 147 |
+
"url": "http://localhost:8000",
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
+
client = Client(config)
|
| 152 |
+
assert isinstance(client.transport.transport, StreamableHttpTransport)
|
| 153 |
+
assert client.transport.transport.auth is None
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def test_remote_config_with_auth_token():
|
| 157 |
+
config = {
|
| 158 |
+
"mcpServers": {
|
| 159 |
+
"test_server": {
|
| 160 |
+
"url": "http://localhost:8000",
|
| 161 |
+
"auth": "test_token",
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
client = Client(config)
|
| 166 |
+
assert isinstance(client.transport.transport, StreamableHttpTransport)
|
| 167 |
+
assert isinstance(client.transport.transport.auth, BearerAuth)
|
| 168 |
+
assert client.transport.transport.auth.token.get_secret_value() == "test_token"
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
async def test_remote_config_sse_with_auth_token():
|
| 172 |
+
config = {
|
| 173 |
+
"mcpServers": {
|
| 174 |
+
"test_server": {
|
| 175 |
+
"url": "http://localhost:8000/sse",
|
| 176 |
+
"auth": "test_token",
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
client = Client(config)
|
| 181 |
+
assert isinstance(client.transport.transport, SSETransport)
|
| 182 |
+
assert isinstance(client.transport.transport.auth, BearerAuth)
|
| 183 |
+
assert client.transport.transport.auth.token.get_secret_value() == "test_token"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
async def test_remote_config_with_oauth_literal():
|
| 187 |
+
config = {
|
| 188 |
+
"mcpServers": {
|
| 189 |
+
"test_server": {
|
| 190 |
+
"url": "http://localhost:8000",
|
| 191 |
+
"auth": "oauth",
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
client = Client(config)
|
| 196 |
+
assert isinstance(client.transport.transport, StreamableHttpTransport)
|
| 197 |
+
assert isinstance(client.transport.transport.auth, OAuthClientProvider)
|