Add targeted coverage tests (#4230)

Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
Bill Easton 2026-05-25 17:57:28 -05:00 committed by GitHub
commit 802ceaaa6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 411 additions and 5 deletions

View file

@ -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"

View file

@ -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) == "<FastMCPTransport(server='repr-test')>"
@pytest.mark.timeout(10)

View file

@ -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()) == "<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():

View file

@ -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."""

View file

@ -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

View file

@ -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"

39
tests/test_settings.py Normal file
View file

@ -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."

View file

@ -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:

View file

@ -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))

View file

@ -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):

View file

@ -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"))