diff --git a/tests/client/test_logs.py b/tests/client/test_logs.py index 2952016bd..123179b4a 100644 --- a/tests/client/test_logs.py +++ b/tests/client/test_logs.py @@ -2,9 +2,10 @@ import logging import pytest from mcp import LoggingLevel +from mcp.types import LoggingMessageNotificationParams from fastmcp import Client, Context, FastMCP -from fastmcp.client.logging import LogMessage +from fastmcp.client.logging import LogMessage, create_log_callback class LogHandler: @@ -333,3 +334,38 @@ class TestDefaultLogHandler: mock_logger.error.assert_called_once_with( msg="Received ERROR from server: 404" ) + + +class TestCreateLogCallback: + async def test_callback_invokes_custom_handler(self): + seen: list[LoggingMessageNotificationParams] = [] + + async def handler(message: LoggingMessageNotificationParams) -> None: + seen.append(message) + + message = LoggingMessageNotificationParams( + level="info", + logger="test.logger", + data={"msg": "hello"}, + ) + callback = create_log_callback(handler) + + await callback(message) + + assert seen == [message] + + async def test_callback_uses_default_handler_when_none_is_provided(self, caplog): + caplog.set_level(logging.WARNING, logger="fastmcp.client.from_server") + message = LoggingMessageNotificationParams( + level="warning", + logger=None, + data="default", + ) + callback = create_log_callback() + + await callback(message) + + assert len(caplog.records) == 1 + assert caplog.records[0].name == "fastmcp.client.from_server" + assert caplog.records[0].levelname == "WARNING" + assert caplog.records[0].msg == "Received WARNING from server: default" diff --git a/tests/client/transports/test_memory_transport.py b/tests/client/transports/test_memory_transport.py index 5abdbdfb3..e5eab41cd 100644 --- a/tests/client/transports/test_memory_transport.py +++ b/tests/client/transports/test_memory_transport.py @@ -9,6 +9,13 @@ import time import pytest from fastmcp import Client, FastMCP +from fastmcp.client.transports import FastMCPTransport + + +def test_transport_repr_includes_server_name(): + transport = FastMCPTransport(FastMCP("repr-test")) + + assert repr(transport) == "" @pytest.mark.timeout(10) diff --git a/tests/client/transports/test_transports.py b/tests/client/transports/test_transports.py index fa9dd710c..79e85bab9 100644 --- a/tests/client/transports/test_transports.py +++ b/tests/client/transports/test_transports.py @@ -1,6 +1,8 @@ +import contextlib import ssl +from collections.abc import AsyncIterator from ssl import VerifyMode -from typing import cast +from typing import Any, cast import httpx import pytest @@ -8,7 +10,35 @@ from mcp.shared._httpx_utils import McpHttpClientFactory from fastmcp import Client from fastmcp.client.auth.oauth import OAuth -from fastmcp.client.transports import SSETransport, StreamableHttpTransport +from fastmcp.client.transports import ( + ClientTransport, + SSETransport, + StreamableHttpTransport, +) + + +class BasicTransport(ClientTransport): + @contextlib.asynccontextmanager + async def connect_session(self, **session_kwargs: Any) -> AsyncIterator[Any]: + raise AssertionError("BasicTransport does not create sessions") + yield + + +class TestClientTransport: + def test_default_repr_uses_subclass_name(self): + assert repr(BasicTransport()) == "" + + def test_default_session_id_is_none(self): + assert BasicTransport().get_session_id() is None + + def test_client_rejects_auth_for_transports_without_auth_support(self): + with pytest.raises(ValueError, match="does not support auth"): + Client(BasicTransport(), auth="oauth") + + def test_client_accepts_none_auth_for_transports_without_auth_support(self): + client = Client(BasicTransport(), auth=None) + + assert isinstance(client.transport, BasicTransport) async def test_oauth_uses_same_client_as_transport_streamable_http(): diff --git a/tests/resources/test_function_resources.py b/tests/resources/test_function_resources.py index 67bedc044..2680b42e3 100644 --- a/tests/resources/test_function_resources.py +++ b/tests/resources/test_function_resources.py @@ -162,6 +162,23 @@ class TestFunctionResource: assert result.contents[0].content == "Hello, world!" assert result.contents[0].mime_type == "text/plain" + async def test_sync_function_returning_awaitable_is_awaited(self): + """Test sync wrappers that return awaitables.""" + + async def get_data() -> str: + return "Hello from awaitable" + + def sync_wrapper(): + return get_data() + + resource = FunctionResource( + uri=AnyUrl("function://test"), + name="test", + fn=sync_wrapper, + ) + + assert await resource.read() == "Hello from awaitable" + async def test_resource_content_text(self): """Test returning ResourceContent with text content.""" diff --git a/tests/server/auth/providers/test_debug.py b/tests/server/auth/providers/test_debug.py new file mode 100644 index 000000000..1bb9f2924 --- /dev/null +++ b/tests/server/auth/providers/test_debug.py @@ -0,0 +1,37 @@ +import pytest + +from fastmcp.server.auth.providers.debug import DebugTokenVerifier + + +class TestDebugTokenVerifier: + async def test_default_validator_accepts_non_empty_tokens(self): + verifier = DebugTokenVerifier(client_id="client-1", scopes=["read"]) + + token = await verifier.verify_token("token-123") + + assert token is not None + assert token.token == "token-123" + assert token.client_id == "client-1" + assert token.scopes == ["read"] + assert token.claims == {"token": "token-123"} + + @pytest.mark.parametrize("token", ["", " "]) + async def test_rejects_empty_tokens(self, token): + verifier = DebugTokenVerifier() + + assert await verifier.verify_token(token) is None + + async def test_sync_validator_can_reject_tokens(self): + verifier = DebugTokenVerifier(validate=lambda token: token.startswith("valid-")) + + assert await verifier.verify_token("invalid") is None + assert await verifier.verify_token("valid-token") is not None + + async def test_async_validator_is_awaited(self): + async def validate(token: str) -> bool: + return token == "allowed" + + verifier = DebugTokenVerifier(validate=validate) + + assert await verifier.verify_token("denied") is None + assert await verifier.verify_token("allowed") is not None diff --git a/tests/server/test_event_store.py b/tests/server/test_event_store.py index 54fbfa828..533844365 100644 --- a/tests/server/test_event_store.py +++ b/tests/server/test_event_store.py @@ -71,7 +71,7 @@ class TestEventStore: ): # Store some events first_event_id = await event_store.store_event("stream-1", sample_message) - await event_store.store_event("stream-1", sample_message) + second_event_id = await event_store.store_event("stream-1", sample_message) # Replay events after the first one replayed_events: list[EventMessage] = [] @@ -82,6 +82,10 @@ class TestEventStore: stream_id = await event_store.replay_events_after(first_event_id, callback) assert stream_id == "stream-1" assert len(replayed_events) == 1 + assert replayed_events[0].event_id == second_event_id + replayed_message = replayed_events[0].message.root + assert isinstance(replayed_message, JSONRPCRequest) + assert replayed_message.method == "test" async def test_replay_events_after_skips_priming_events(self, event_store): """Priming events (message=None) should not be replayed.""" @@ -233,6 +237,7 @@ class TestEventStoreIntegration: await event_store.replay_events_after(event_id, callback) assert len(replayed) == 1 + assert replayed[0].event_id is not None assert isinstance(replayed[0].message.root, JSONRPCRequest) assert replayed[0].message.root.method == "tools/call" assert replayed[0].message.root.id == "request-456" diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 000000000..75c0d46d2 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,39 @@ +import pytest + +from fastmcp import settings +from fastmcp.settings import Settings +from fastmcp.utilities.tests import temporary_settings + + +def test_get_setting_reads_nested_values(): + test_settings = Settings() + + assert test_settings.get_setting("docket__name") == "fastmcp" + assert test_settings.get_setting("docket__redelivery_timeout__seconds") == 300 + + +def test_set_setting_updates_nested_values(): + test_settings = Settings() + + test_settings.set_setting("docket__name", "worker-queue") + + assert test_settings.docket.name == "worker-queue" + assert test_settings.get_setting("docket__name") == "worker-queue" + + +def test_temporary_settings_restores_nested_values(): + original_name = settings.get_setting("docket__name") + + with temporary_settings(docket__name="temporary-queue"): + assert settings.get_setting("docket__name") == "temporary-queue" + + assert settings.get_setting("docket__name") == original_name + + +def test_get_setting_raises_for_missing_nested_parent(): + test_settings = Settings() + + with pytest.raises(AttributeError) as exc_info: + test_settings.get_setting("docket__missing__value") + + assert str(exc_info.value) == "Setting missing does not exist." diff --git a/tests/utilities/test_async_utils.py b/tests/utilities/test_async_utils.py index 03177007d..6d1366613 100644 --- a/tests/utilities/test_async_utils.py +++ b/tests/utilities/test_async_utils.py @@ -3,12 +3,13 @@ import functools import pytest +from exceptiongroup import BaseExceptionGroup from fastmcp import Client, FastMCP from fastmcp.prompts import prompt from fastmcp.resources import resource from fastmcp.tools import tool -from fastmcp.utilities.async_utils import is_coroutine_function +from fastmcp.utilities.async_utils import gather, is_coroutine_function async def _async_fn(x: int) -> int: @@ -49,6 +50,36 @@ class TestIsCoroutineFunction: assert is_coroutine_function(42) is False +class TestGather: + async def test_returns_results_in_input_order(self) -> None: + async def value(result: int) -> int: + return result + + assert await gather(value(1), value(2), value(3)) == [1, 2, 3] + + async def test_raises_by_default(self) -> None: + async def fail() -> int: + raise RuntimeError("boom") + + with pytest.raises(BaseExceptionGroup) as exc_info: + await gather(fail()) + + assert len(exc_info.value.exceptions) == 1 + assert isinstance(exc_info.value.exceptions[0], RuntimeError) + + async def test_return_exceptions_collects_exceptions(self) -> None: + async def fail() -> int: + raise ValueError("bad") + + async def value() -> int: + return 1 + + result = await gather(fail(), value(), return_exceptions=True) + + assert isinstance(result[0], ValueError) + assert result[1] == 1 + + class TestAsyncPartialIntegration: async def test_async_partial_tool_runs(self) -> None: async def greet(greeting: str, name: str) -> str: diff --git a/tests/utilities/test_http.py b/tests/utilities/test_http.py new file mode 100644 index 000000000..f03dd0832 --- /dev/null +++ b/tests/utilities/test_http.py @@ -0,0 +1,15 @@ +"""Tests for HTTP utility helpers.""" + +import socket + +from fastmcp.utilities.http import find_available_port + + +def test_find_available_port_returns_bindable_loopback_port(): + port = find_available_port() + + assert isinstance(port, int) + assert port > 0 + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.bind(("127.0.0.1", port)) diff --git a/tests/utilities/test_skills.py b/tests/utilities/test_skills.py index 46170529d..964ffa021 100644 --- a/tests/utilities/test_skills.py +++ b/tests/utilities/test_skills.py @@ -2,9 +2,14 @@ from __future__ import annotations +import base64 +import json from pathlib import Path +from typing import cast import pytest +from mcp.types import BlobResourceContents, TextResourceContents +from pydantic import AnyUrl from fastmcp import Client, FastMCP from fastmcp.server.providers.skills import SkillsDirectoryProvider @@ -19,6 +24,30 @@ from fastmcp.utilities.skills import ( ) +class FakeResourceReader: + def __init__( + self, + responses: dict[str, list[TextResourceContents | BlobResourceContents]], + ) -> None: + self.responses = responses + self.requested_uris: list[str] = [] + + async def read_resource(self, uri: str): + self.requested_uris.append(uri) + return self.responses.get(uri, []) + + +def text_resource(uri: str, text: str) -> TextResourceContents: + return TextResourceContents(uri=AnyUrl(uri), text=text) + + +def blob_resource(uri: str, data: bytes) -> BlobResourceContents: + return BlobResourceContents( + uri=AnyUrl(uri), + blob=base64.b64encode(data).decode(), + ) + + @pytest.fixture def skills_dir(tmp_path: Path) -> Path: """Create a temporary skills directory with sample skills.""" @@ -107,6 +136,36 @@ class TestListSkills: assert skills == [] + async def test_filters_non_skill_resources(self): + mcp = FastMCP("Mixed Resources") + + @mcp.resource("skill://team/pdf/SKILL.md") + def skill_document(): + return "# Team PDF" + + @mcp.resource("skill://team/pdf/reference.md") + def skill_reference(): + return "# Reference" + + @mcp.resource("file:///tmp/SKILL.md") + def non_skill_document(): + return "# File" + + @mcp.resource("skill://broken/SKILL.txt") + def wrong_suffix(): + return "# Wrong suffix" + + async with Client(mcp) as client: + skills = await list_skills(client) + + assert skills == [ + SkillSummary( + name="team/pdf", + description="", + uri="skill://team/pdf/SKILL.md", + ) + ] + class TestGetSkillManifest: async def test_returns_manifest_with_files(self, skills_server: FastMCP): @@ -141,6 +200,34 @@ class TestGetSkillManifest: with pytest.raises(Exception): await get_skill_manifest(client, "nonexistent") + @pytest.mark.parametrize( + ("response", "match"), + [ + ([], "Could not read manifest"), + ( + [text_resource("skill://broken/_manifest", "not json")], + "Invalid manifest JSON", + ), + ( + [blob_resource("skill://broken/_manifest", b"{}")], + "Unexpected manifest format", + ), + ( + [text_resource("skill://broken/_manifest", '{"skill": "broken"}')], + "Invalid manifest format", + ), + ], + ) + async def test_invalid_manifest_responses_raise_value_error( + self, + response: list[TextResourceContents | BlobResourceContents], + match: str, + ): + client = FakeResourceReader({"skill://broken/_manifest": response}) + + with pytest.raises(ValueError, match=match): + await get_skill_manifest(cast(Client, client), "broken") + class TestDownloadSkill: async def test_downloads_skill_to_directory( @@ -215,6 +302,58 @@ class TestDownloadSkill: assert result.exists() + async def test_skips_manifest_paths_that_escape_target(self, tmp_path: Path): + manifest = { + "skill": "malicious", + "files": [ + {"path": "SKILL.md", "size": 6, "hash": "sha256:safe"}, + {"path": "../escape.txt", "size": 6, "hash": "sha256:escape"}, + {"path": "/absolute.txt", "size": 8, "hash": "sha256:absolute"}, + ], + } + + client = FakeResourceReader( + { + "skill://malicious/_manifest": [ + text_resource("skill://malicious/_manifest", json.dumps(manifest)) + ], + "skill://malicious/SKILL.md": [ + text_resource("skill://malicious/SKILL.md", "# Safe") + ], + } + ) + + result = await download_skill(cast(Client, client), "malicious", tmp_path) + + assert (result / "SKILL.md").read_text() == "# Safe" + assert not (tmp_path / "escape.txt").exists() + assert client.requested_uris == [ + "skill://malicious/_manifest", + "skill://malicious/SKILL.md", + ] + + async def test_downloads_blob_resources(self, tmp_path: Path): + data = b"\x00\x01binary data" + manifest = { + "skill": "binary", + "files": [{"path": "data.bin", "size": len(data), "hash": "sha256:data"}], + } + + client = FakeResourceReader( + { + "skill://binary/_manifest": [ + text_resource("skill://binary/_manifest", json.dumps(manifest)) + ], + "skill://binary/data.bin": [ + blob_resource("skill://binary/data.bin", data) + ], + } + ) + + result = await download_skill(cast(Client, client), "binary", tmp_path) + + assert (result / "data.bin").read_bytes() == data + class TestSyncSkills: async def test_downloads_all_skills(self, skills_server: FastMCP, tmp_path: Path): diff --git a/tests/utilities/test_timeout.py b/tests/utilities/test_timeout.py new file mode 100644 index 000000000..6f28860cd --- /dev/null +++ b/tests/utilities/test_timeout.py @@ -0,0 +1,50 @@ +"""Tests for timeout normalization utilities.""" + +import datetime +from typing import Any, cast + +import pytest + +from fastmcp.utilities.timeout import ( + normalize_timeout_to_seconds, + normalize_timeout_to_timedelta, +) + + +class TestNormalizeTimeoutToSeconds: + def test_none_stays_none(self): + assert normalize_timeout_to_seconds(None) is None + + def test_numeric_values_become_float_seconds(self): + assert normalize_timeout_to_seconds(3) == 3.0 + assert normalize_timeout_to_seconds(2.5) == 2.5 + + def test_zero_values_disable_timeout(self): + assert normalize_timeout_to_seconds(0) is None + assert normalize_timeout_to_seconds(datetime.timedelta(seconds=0)) is None + + def test_timedelta_becomes_seconds(self): + assert ( + normalize_timeout_to_seconds(datetime.timedelta(milliseconds=250)) == 0.25 + ) + + def test_invalid_type_raises_type_error(self): + with pytest.raises(TypeError, match="Invalid timeout type"): + normalize_timeout_to_seconds(cast(Any, "1")) + + +class TestNormalizeTimeoutToTimedelta: + def test_none_stays_none(self): + assert normalize_timeout_to_timedelta(None) is None + + def test_timedelta_is_returned_unchanged(self): + timeout = datetime.timedelta(seconds=5) + assert normalize_timeout_to_timedelta(timeout) is timeout + + def test_numeric_values_become_timedeltas(self): + assert normalize_timeout_to_timedelta(3) == datetime.timedelta(seconds=3) + assert normalize_timeout_to_timedelta(0.5) == datetime.timedelta(seconds=0.5) + + def test_invalid_type_raises_type_error(self): + with pytest.raises(TypeError, match="Invalid timeout type"): + normalize_timeout_to_timedelta(cast(Any, "1"))