60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
165 lines
6.1 KiB
Python
165 lines
6.1 KiB
Python
"""Tests for CIMD (Client ID Metadata Document) support in the OAuth client."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import warnings
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from fastmcp.client.auth import OAuth
|
|
from fastmcp.client.transports import StreamableHttpTransport
|
|
from fastmcp.client.transports.sse import SSETransport
|
|
|
|
VALID_CIMD_URL = "https://myapp.example.com/oauth/client.json"
|
|
MCP_SERVER_URL = "https://mcp-server.example.com/mcp"
|
|
|
|
|
|
class TestOAuthClientMetadataURL:
|
|
"""Tests for the client_metadata_url parameter on OAuth."""
|
|
|
|
def test_stored_on_instance(self):
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
assert oauth._client_metadata_url == VALID_CIMD_URL
|
|
|
|
def test_none_by_default(self):
|
|
oauth = OAuth()
|
|
assert oauth._client_metadata_url is None
|
|
|
|
def test_passed_to_parent_on_bind(self):
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
oauth._bind(MCP_SERVER_URL)
|
|
assert oauth.context.client_metadata_url == VALID_CIMD_URL
|
|
|
|
def test_none_metadata_url_on_parent(self):
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth = OAuth(mcp_url=MCP_SERVER_URL)
|
|
assert oauth.context.client_metadata_url is None
|
|
|
|
def test_unbound_when_no_mcp_url(self):
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
assert oauth._bound is False
|
|
|
|
def test_bound_when_mcp_url_provided(self):
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth = OAuth(
|
|
mcp_url=MCP_SERVER_URL,
|
|
client_metadata_url=VALID_CIMD_URL,
|
|
)
|
|
assert oauth._bound is True
|
|
|
|
def test_invalid_cimd_url_rejected(self):
|
|
"""CIMD URLs must be HTTPS with a non-root path."""
|
|
with pytest.raises(ValueError, match="valid HTTPS URL"):
|
|
OAuth(
|
|
mcp_url=MCP_SERVER_URL,
|
|
client_metadata_url="http://insecure.com/client.json",
|
|
)
|
|
|
|
def test_root_path_cimd_url_rejected(self):
|
|
with pytest.raises(ValueError, match="valid HTTPS URL"):
|
|
OAuth(
|
|
mcp_url=MCP_SERVER_URL,
|
|
client_metadata_url="https://example.com/",
|
|
)
|
|
|
|
|
|
class TestOAuthBind:
|
|
"""Tests for the _bind() deferred initialization."""
|
|
|
|
def test_bind_sets_bound_true(self):
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
assert oauth._bound is False
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth._bind(MCP_SERVER_URL)
|
|
assert oauth._bound is True
|
|
|
|
def test_bind_idempotent(self):
|
|
"""Second call to _bind is a no-op."""
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth._bind(MCP_SERVER_URL)
|
|
oauth._bind("https://other-server.example.com/mcp")
|
|
# First binding wins
|
|
assert oauth.mcp_url == MCP_SERVER_URL
|
|
|
|
def test_bind_sets_mcp_url(self):
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth._bind(MCP_SERVER_URL + "/")
|
|
# Trailing slash stripped
|
|
assert oauth.mcp_url == MCP_SERVER_URL
|
|
|
|
def test_bind_creates_token_storage(self):
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
assert not hasattr(oauth, "token_storage_adapter")
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth._bind(MCP_SERVER_URL)
|
|
assert hasattr(oauth, "token_storage_adapter")
|
|
|
|
async def test_unbound_raises_runtime_error(self):
|
|
"""async_auth_flow should fail clearly when OAuth is not bound."""
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
request = httpx.Request("GET", MCP_SERVER_URL)
|
|
with pytest.raises(RuntimeError, match="no server URL"):
|
|
async for _ in oauth.async_auth_flow(request):
|
|
pass
|
|
|
|
def test_scopes_forwarded_as_list(self):
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth = OAuth(
|
|
client_metadata_url=VALID_CIMD_URL,
|
|
scopes=["read", "write"],
|
|
)
|
|
oauth._bind(MCP_SERVER_URL)
|
|
assert oauth.context.client_metadata.scope == "read write"
|
|
|
|
def test_scopes_forwarded_as_string(self):
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
oauth = OAuth(
|
|
client_metadata_url=VALID_CIMD_URL,
|
|
scopes="read write",
|
|
)
|
|
oauth._bind(MCP_SERVER_URL)
|
|
assert oauth.context.client_metadata.scope == "read write"
|
|
|
|
|
|
class TestOAuthBindFromTransport:
|
|
"""Tests that transports call _bind() on OAuth instances."""
|
|
|
|
def test_http_transport_binds_oauth(self):
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
assert oauth._bound is False
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
StreamableHttpTransport(MCP_SERVER_URL, auth=oauth)
|
|
assert oauth._bound is True
|
|
assert oauth.mcp_url == MCP_SERVER_URL
|
|
|
|
def test_sse_transport_binds_oauth(self):
|
|
oauth = OAuth(client_metadata_url=VALID_CIMD_URL)
|
|
assert oauth._bound is False
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
SSETransport(MCP_SERVER_URL, auth=oauth)
|
|
assert oauth._bound is True
|
|
assert oauth.mcp_url == MCP_SERVER_URL
|
|
|
|
def test_http_transport_oauth_string_still_works(self):
|
|
"""auth="oauth" should still create a new OAuth instance."""
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore", UserWarning)
|
|
transport = StreamableHttpTransport(MCP_SERVER_URL, auth="oauth")
|
|
assert isinstance(transport.auth, OAuth)
|
|
assert transport.auth._bound is True
|