mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Add targeted coverage tests (#4230)
Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
This commit is contained in:
parent
2bff3725bf
commit
802ceaaa6b
11 changed files with 411 additions and 5 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
37
tests/server/auth/providers/test_debug.py
Normal file
37
tests/server/auth/providers/test_debug.py
Normal 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
|
||||
|
|
@ -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
39
tests/test_settings.py
Normal 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."
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
15
tests/utilities/test_http.py
Normal file
15
tests/utilities/test_http.py
Normal 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))
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
50
tests/utilities/test_timeout.py
Normal file
50
tests/utilities/test_timeout.py
Normal 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"))
|
||||
Loading…
Add table
Add a link
Reference in a new issue