mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 04:24:17 +02:00
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com> Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com> Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com> Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: nightcityblade <nightcityblade@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Bill Easton <strawgate@users.noreply.github.com> Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com> Co-authored-by: Eric Robinson <ericrobinson@indeed.com> Co-authored-by: Martim Santos <martimfasantos@gmail.com> Co-authored-by: d 🔹 <liusway405@gmail.com> Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com> Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com> Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com> Co-authored-by: nightcityblade <jackchen@haloailabs.com> Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com> Co-authored-by: nate nowack <thrast36@gmail.com> Co-authored-by: Bill Easton <williamseaston@gmail.com> Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com> Co-authored-by: Rushabh Doshi <radoshi@gmail.com> Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp> Co-authored-by: Jeremy Simon <simonjer805@gmail.com> Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com> Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com> Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com> Fix auto-close MRE script posting comment without closing (#3386) Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407) Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413) Fix transform arg collisions with passthrough params (#3431) Fix get_* returning None when latest version is disabled (#3439) Fix get_* returning None when latest version is disabled (#3421) Fix server lifespan overlap teardown (#3415) Fix $ref output schema object detection regression (#3420) resolved annotations (#3429) Fix async partial callables rejected by iscoroutinefunction (#3438) Fix async partial callables rejected by iscoroutinefunction (#3423) fix: add version to components (#3458) fix: use intent-based flag for OIDC scope patch in load_access_token (#3465) Fixes #3461 fix: normalize Google scope shorthands and surface valid_scopes (#3477) fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481) fix: shield lifespan teardown from cancellation (#3480) fix: forward custom_route endpoints from mounted servers (#3462) fix updates _get_additional_http_routes() to traverse providers, Fixes #3457 fix: remove hardcoded version from CLI help text (#3456) fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468) fix: task test teardown hanging 5s per test (#3499) Closes #3498 fix: validate workspace path is a directory before cursor install (#3440) Fixes #3426 fix: handle re.error from malformed URI templates in build_regex (#3501) fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503) fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502) fix warnings and timeouts (#3504) close upgrade check issue when build passes (#3505) Closes #3484 fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507) fix: prevent path traversal in skill download (#3493) fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492) fix: remove unrelated transform and http.py changes from PR scope fix: remove forced follow_redirects from httpx_client_factory calls (#3496) fix: stop passing follow_redirects to httpx_client_factory fix: restore follow_redirects=True for custom httpx client factories Closes #3509 fix: CSRF double-submit cookie check in consent flow (#3519) fix: validate server names in install commands (#3522) fix: use raw strings for regex in pytest.raises match (#3523) fix: reject refresh tokens used as Bearer access tokens (#3524) fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495) fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540) fix: filter warnings by message in KEY_PREFIX test (#3549) fix: suppress output schema for ToolResult subclass annotations (#3548) fix: increase sleep duration in proxy cache tests (#3567) fix: store absolute token expiry to prevent stale expires_in on reload (#3572) fix: preserve tool properties named 'title' during schema compression (#3582) Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589) Fix app tool routing: visibility check and middleware propagation (#3591) Fix query parameter serialization to respect OpenAPI explode/style settings (#3595) Fix dev apps form: union types, textarea support, JSON parsing (#3597) fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603) fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609) fix(docs): correct misleading stateless_http header (#3622) fix: filesystem provider import machinery (#3626) Closes #3625 (issues 2, 3, 6) fix: recover StdioTransport after subprocess exits (#3630) fix(server): preserve mounted tool task metadata (#3632) fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649) fix imports, add PrefabAppConfig (#3650) fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651) Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652) closed by default (#3657) Fix prompt caching middleware missing wrap/unwrap round-trip (#3666) fix: serialize object query params per OpenAPI style/explode rules (#3662) Fixes #2857 fix: HTTP request headers not accessible in background task workers (#3631) fix: restore HTTP headers in worker execution path for background tasks (#3681) fix: strip discriminator after dereferencing schemas (#3682) fix: remove stale ty:ignore directives for ty 0.0.26 (#3684) Fix docs gaps in app provider pages (#3690) fix: dev apps log panel UX improvements (#3698) fix dev server empty string args (#3700)
335 lines
13 KiB
Python
335 lines
13 KiB
Python
import logging
|
|
|
|
import pytest
|
|
from mcp import LoggingLevel
|
|
|
|
from fastmcp import Client, Context, FastMCP
|
|
from fastmcp.client.logging import LogMessage
|
|
|
|
|
|
class LogHandler:
|
|
def __init__(self):
|
|
self.logs: list[LogMessage] = []
|
|
self.logger = logging.getLogger(__name__)
|
|
# Backwards-compatible way to get the log level mapping
|
|
if hasattr(logging, "getLevelNamesMapping"):
|
|
# For Python 3.11+
|
|
self.LOGGING_LEVEL_MAP = logging.getLevelNamesMapping() # pyright: ignore [reportAttributeAccessIssue]
|
|
else:
|
|
# For older Python versions
|
|
self.LOGGING_LEVEL_MAP = logging._nameToLevel
|
|
|
|
async def handle_log(self, message: LogMessage) -> None:
|
|
self.logs.append(message)
|
|
|
|
level = self.LOGGING_LEVEL_MAP[message.level.upper()]
|
|
msg = message.data.get("msg")
|
|
extra = message.data.get("extra")
|
|
self.logger.log(level, msg, extra=extra)
|
|
|
|
|
|
@pytest.fixture
|
|
def fastmcp_server():
|
|
mcp = FastMCP()
|
|
|
|
@mcp.tool
|
|
async def log(context: Context) -> None:
|
|
await context.info(message="hello?")
|
|
|
|
@mcp.tool
|
|
async def echo_log(
|
|
message: str,
|
|
context: Context,
|
|
level: LoggingLevel | None = None,
|
|
logger: str | None = None,
|
|
) -> None:
|
|
await context.log(message=message, level=level)
|
|
|
|
return mcp
|
|
|
|
|
|
class TestClientLogs:
|
|
async def test_log(self, fastmcp_server: FastMCP, caplog):
|
|
caplog.set_level(logging.INFO, logger=__name__)
|
|
|
|
log_handler = LogHandler()
|
|
async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
|
|
await client.call_tool("log", {})
|
|
|
|
assert len(log_handler.logs) == 1
|
|
assert log_handler.logs[0].data["msg"] == "hello?"
|
|
assert log_handler.logs[0].level == "info"
|
|
|
|
assert len(caplog.records) == 1
|
|
assert caplog.records[0].msg == "hello?"
|
|
assert caplog.records[0].levelname == "INFO"
|
|
|
|
async def test_echo_log(self, fastmcp_server: FastMCP, caplog):
|
|
caplog.set_level(logging.INFO, logger=__name__)
|
|
|
|
log_handler = LogHandler()
|
|
async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
|
|
await client.call_tool("echo_log", {"message": "this is a log"})
|
|
|
|
assert len(log_handler.logs) == 1
|
|
assert len(caplog.records) == 1
|
|
await client.call_tool(
|
|
"echo_log", {"message": "this is a warning log", "level": "warning"}
|
|
)
|
|
assert len(log_handler.logs) == 2
|
|
assert len(caplog.records) == 2
|
|
|
|
assert log_handler.logs[0].data["msg"] == "this is a log"
|
|
assert log_handler.logs[0].level == "info"
|
|
assert log_handler.logs[1].data["msg"] == "this is a warning log"
|
|
assert log_handler.logs[1].level == "warning"
|
|
|
|
assert caplog.records[0].msg == "this is a log"
|
|
assert caplog.records[0].levelname == "INFO"
|
|
assert caplog.records[1].msg == "this is a warning log"
|
|
assert caplog.records[1].levelname == "WARNING"
|
|
|
|
|
|
class TestSetLoggingLevel:
|
|
async def test_set_logging_level(self, fastmcp_server: FastMCP):
|
|
"""Client can set the minimum log level and lower-level messages are suppressed."""
|
|
log_handler = LogHandler()
|
|
async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
|
|
await client.set_logging_level("warning")
|
|
await client.call_tool(
|
|
"echo_log", {"message": "debug msg", "level": "debug"}
|
|
)
|
|
await client.call_tool("echo_log", {"message": "info msg", "level": "info"})
|
|
await client.call_tool(
|
|
"echo_log", {"message": "warning msg", "level": "warning"}
|
|
)
|
|
await client.call_tool(
|
|
"echo_log", {"message": "error msg", "level": "error"}
|
|
)
|
|
|
|
assert len(log_handler.logs) == 2
|
|
assert log_handler.logs[0].data["msg"] == "warning msg"
|
|
assert log_handler.logs[1].data["msg"] == "error msg"
|
|
|
|
async def test_set_logging_level_debug_allows_all(self, fastmcp_server: FastMCP):
|
|
"""Setting level to debug allows all messages through."""
|
|
log_handler = LogHandler()
|
|
async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
|
|
await client.set_logging_level("debug")
|
|
await client.call_tool(
|
|
"echo_log", {"message": "debug msg", "level": "debug"}
|
|
)
|
|
await client.call_tool("echo_log", {"message": "info msg", "level": "info"})
|
|
|
|
assert len(log_handler.logs) == 2
|
|
|
|
async def test_default_level_allows_all(self, fastmcp_server: FastMCP):
|
|
"""Without calling set_logging_level, all messages are sent."""
|
|
log_handler = LogHandler()
|
|
async with Client(fastmcp_server, log_handler=log_handler.handle_log) as client:
|
|
await client.call_tool(
|
|
"echo_log", {"message": "debug msg", "level": "debug"}
|
|
)
|
|
await client.call_tool("echo_log", {"message": "info msg", "level": "info"})
|
|
|
|
assert len(log_handler.logs) == 2
|
|
|
|
async def test_server_default_client_log_level(self):
|
|
"""Server-wide client_log_level filters messages for all sessions."""
|
|
mcp = FastMCP(client_log_level="error")
|
|
|
|
@mcp.tool
|
|
async def echo_log(
|
|
message: str, context: Context, level: LoggingLevel | None = None
|
|
) -> None:
|
|
await context.log(message=message, level=level)
|
|
|
|
log_handler = LogHandler()
|
|
async with Client(mcp, log_handler=log_handler.handle_log) as client:
|
|
await client.call_tool("echo_log", {"message": "info msg", "level": "info"})
|
|
await client.call_tool(
|
|
"echo_log", {"message": "warning msg", "level": "warning"}
|
|
)
|
|
await client.call_tool(
|
|
"echo_log", {"message": "error msg", "level": "error"}
|
|
)
|
|
|
|
assert len(log_handler.logs) == 1
|
|
assert log_handler.logs[0].data["msg"] == "error msg"
|
|
|
|
async def test_session_level_overrides_server_default(self):
|
|
"""Per-session setLevel overrides the server's client_log_level."""
|
|
mcp = FastMCP(client_log_level="error")
|
|
|
|
@mcp.tool
|
|
async def echo_log(
|
|
message: str, context: Context, level: LoggingLevel | None = None
|
|
) -> None:
|
|
await context.log(message=message, level=level)
|
|
|
|
log_handler = LogHandler()
|
|
async with Client(mcp, log_handler=log_handler.handle_log) as client:
|
|
await client.set_logging_level("warning")
|
|
await client.call_tool("echo_log", {"message": "info msg", "level": "info"})
|
|
await client.call_tool(
|
|
"echo_log", {"message": "warning msg", "level": "warning"}
|
|
)
|
|
await client.call_tool(
|
|
"echo_log", {"message": "error msg", "level": "error"}
|
|
)
|
|
|
|
assert len(log_handler.logs) == 2
|
|
assert log_handler.logs[0].data["msg"] == "warning msg"
|
|
assert log_handler.logs[1].data["msg"] == "error msg"
|
|
|
|
|
|
class TestDefaultLogHandler:
|
|
"""Tests for default_log_handler with data as any JSON-serializable type."""
|
|
|
|
async def test_default_handler_routes_to_correct_levels(self):
|
|
"""Test that default_log_handler routes server logs to appropriate Python log levels."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from mcp.types import LoggingMessageNotificationParams
|
|
|
|
from fastmcp.client.logging import default_log_handler
|
|
|
|
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
|
|
# Set up mock methods
|
|
mock_logger.debug = MagicMock()
|
|
mock_logger.info = MagicMock()
|
|
mock_logger.warning = MagicMock()
|
|
mock_logger.error = MagicMock()
|
|
mock_logger.critical = MagicMock()
|
|
|
|
# Test each log level
|
|
test_cases = [
|
|
("debug", mock_logger.debug, "Debug message"),
|
|
("info", mock_logger.info, "Info message"),
|
|
("notice", mock_logger.info, "Notice message"), # notice -> info
|
|
("warning", mock_logger.warning, "Warning message"),
|
|
("error", mock_logger.error, "Error message"),
|
|
("critical", mock_logger.critical, "Critical message"),
|
|
("alert", mock_logger.critical, "Alert message"), # alert -> critical
|
|
(
|
|
"emergency",
|
|
mock_logger.critical,
|
|
"Emergency message",
|
|
), # emergency -> critical
|
|
]
|
|
|
|
for level, expected_method, msg in test_cases:
|
|
# Reset mocks
|
|
mock_logger.reset_mock()
|
|
|
|
# Create log message with data as a string
|
|
log_msg = LoggingMessageNotificationParams(
|
|
level=level, # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
|
|
logger="test.logger",
|
|
data=msg,
|
|
)
|
|
|
|
# Call handler
|
|
await default_log_handler(log_msg)
|
|
|
|
# Verify correct method was called
|
|
expected_method.assert_called_once_with(
|
|
msg=f"Received {level.upper()} from server (test.logger): {msg}"
|
|
)
|
|
|
|
async def test_default_handler_without_logger_name(self):
|
|
"""Test that default_log_handler works when logger name is None."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from mcp.types import LoggingMessageNotificationParams
|
|
|
|
from fastmcp.client.logging import default_log_handler
|
|
|
|
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
|
|
mock_logger.info = MagicMock()
|
|
|
|
log_msg = LoggingMessageNotificationParams(
|
|
level="info",
|
|
logger=None,
|
|
data="Message without logger",
|
|
)
|
|
|
|
await default_log_handler(log_msg)
|
|
|
|
mock_logger.info.assert_called_once_with(
|
|
msg="Received INFO from server: Message without logger"
|
|
)
|
|
|
|
async def test_default_handler_with_dict_data(self):
|
|
"""Test that default_log_handler handles dict data correctly."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from mcp.types import LoggingMessageNotificationParams
|
|
|
|
from fastmcp.client.logging import default_log_handler
|
|
|
|
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
|
|
mock_logger.info = MagicMock()
|
|
|
|
log_msg = LoggingMessageNotificationParams(
|
|
level="info",
|
|
logger="test.logger",
|
|
data={"key": "value", "count": 42},
|
|
)
|
|
|
|
await default_log_handler(log_msg)
|
|
|
|
# Should log the entire dict as a string
|
|
mock_logger.info.assert_called_once()
|
|
call_args = mock_logger.info.call_args
|
|
assert "Received INFO from server (test.logger):" in call_args[1]["msg"]
|
|
assert "key" in call_args[1]["msg"]
|
|
assert "value" in call_args[1]["msg"]
|
|
|
|
async def test_default_handler_with_list_data(self):
|
|
"""Test that default_log_handler handles list data correctly."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from mcp.types import LoggingMessageNotificationParams
|
|
|
|
from fastmcp.client.logging import default_log_handler
|
|
|
|
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
|
|
mock_logger.warning = MagicMock()
|
|
|
|
log_msg = LoggingMessageNotificationParams(
|
|
level="warning",
|
|
logger="test.logger",
|
|
data=["item1", "item2", "item3"],
|
|
)
|
|
|
|
await default_log_handler(log_msg)
|
|
|
|
# Should log the entire list as a string
|
|
mock_logger.warning.assert_called_once()
|
|
call_args = mock_logger.warning.call_args
|
|
assert "Received WARNING from server (test.logger):" in call_args[1]["msg"]
|
|
assert "item1" in call_args[1]["msg"]
|
|
|
|
async def test_default_handler_with_number_data(self):
|
|
"""Test that default_log_handler handles numeric data correctly."""
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from mcp.types import LoggingMessageNotificationParams
|
|
|
|
from fastmcp.client.logging import default_log_handler
|
|
|
|
with patch("fastmcp.client.logging.from_server_logger") as mock_logger:
|
|
mock_logger.error = MagicMock()
|
|
|
|
log_msg = LoggingMessageNotificationParams(
|
|
level="error",
|
|
logger=None,
|
|
data=404,
|
|
)
|
|
|
|
await default_log_handler(log_msg)
|
|
|
|
mock_logger.error.assert_called_once_with(
|
|
msg="Received ERROR from server: 404"
|
|
)
|