mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
738 lines
27 KiB
Python
738 lines
27 KiB
Python
import asyncio
|
|
import gc
|
|
import inspect
|
|
import os
|
|
import time
|
|
import weakref
|
|
from pathlib import Path
|
|
|
|
import psutil
|
|
import pytest
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
from fastmcp import Client
|
|
from fastmcp.client.transports import PythonStdioTransport, StdioTransport
|
|
from fastmcp.exceptions import FastMCPError
|
|
|
|
# A pure-stdlib MCP server used by the process-lifecycle tests below. It starts
|
|
# in ~0.03s instead of the ~0.7s a real FastMCP server needs, which matters
|
|
# because these tests spawn several subprocesses each. See its docstring for
|
|
# what it does and does not implement.
|
|
MINIMAL_STDIO_SERVER = Path(__file__).parent / "minimal_stdio_server.py"
|
|
|
|
|
|
def running_under_debugger():
|
|
return os.environ.get("DEBUGPY_RUNNING") == "true"
|
|
|
|
|
|
def gc_collect_harder():
|
|
gc.collect()
|
|
gc.collect()
|
|
gc.collect()
|
|
gc.collect()
|
|
gc.collect()
|
|
gc.collect()
|
|
|
|
|
|
async def wait_for_log_content(
|
|
log_file_path, expected: str, timeout: float = 2.0
|
|
) -> str:
|
|
"""Poll a log file until it contains the expected text.
|
|
|
|
The subprocess's stderr is redirected straight to the file at the OS
|
|
level (no async pump on our side to synchronize on), so poll for the
|
|
content instead of sleeping a fixed amount and hoping it landed.
|
|
"""
|
|
|
|
async def _poll() -> str:
|
|
while True:
|
|
content = log_file_path.read_text()
|
|
if expected in content:
|
|
return content
|
|
await asyncio.sleep(0.01)
|
|
|
|
return await asyncio.wait_for(_poll(), timeout=timeout)
|
|
|
|
|
|
async def wait_for_process_exit(pid: int | None, timeout: float = 5.0) -> None:
|
|
"""Poll until the given pid is gone, failing clearly if it never exits.
|
|
|
|
The subprocesses under test self-terminate within a fraction of a second,
|
|
so a bounded poll costs nothing and turns a hung teardown into a named
|
|
failure instead of an opaque suite-level timeout.
|
|
"""
|
|
assert pid is not None
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
psutil.Process(pid)
|
|
except psutil.NoSuchProcess:
|
|
return
|
|
await asyncio.sleep(0.01)
|
|
pytest.fail(f"Subprocess {pid} was still alive after {timeout}s")
|
|
|
|
|
|
# Exceptions a call may raise while a crashed stdio session is being torn down
|
|
# and replaced. The direct-client path surfaces MCPError (session closed) or
|
|
# RuntimeError (reconnect failed); a proxy wraps the backend failure in a
|
|
# FastMCPError (e.g. ToolError).
|
|
CRASH_RECOVERY_EXCEPTIONS = (MCPError, RuntimeError, FastMCPError)
|
|
|
|
|
|
async def _recover_new_pid(call, old_pid: int, *, attempts: int = 10) -> int:
|
|
"""Call `call` until it succeeds on a subprocess other than `old_pid`.
|
|
|
|
`psutil.Process(pid).kill()` is asynchronous and crash recovery is
|
|
transparent, so a post-crash call may raise while the stale session is torn
|
|
down, briefly still reach the dying old process, or land on a fresh
|
|
subprocess — the outcome depends on scheduling. Retrying until a call
|
|
succeeds on a *new* pid asserts eventual recovery instead of the exact
|
|
number of failed calls, which was never an actual contract.
|
|
"""
|
|
last_exc: BaseException | None = None
|
|
for _ in range(attempts):
|
|
try:
|
|
pid = await call()
|
|
except CRASH_RECOVERY_EXCEPTIONS as exc:
|
|
last_exc = exc
|
|
else:
|
|
if pid != old_pid:
|
|
return pid
|
|
await asyncio.sleep(0.05)
|
|
raise AssertionError(
|
|
f"stdio backend never recovered onto a new subprocess after {attempts} attempts"
|
|
) from last_exc
|
|
|
|
|
|
async def recover_client_pid(client: Client, old_pid: int, **kwargs) -> int:
|
|
"""Reconnect `client` and return the pid of the freshly spawned subprocess."""
|
|
|
|
async def call() -> int:
|
|
async with client:
|
|
result = await client.call_tool("pid")
|
|
return int(result.data)
|
|
|
|
return await _recover_new_pid(call, old_pid, **kwargs)
|
|
|
|
|
|
async def recover_proxy_pid(proxy, old_pid: int, **kwargs) -> int:
|
|
"""Call the proxy and return the pid of the freshly spawned backend subprocess."""
|
|
|
|
async def call() -> int:
|
|
result = await proxy.call_tool("pid")
|
|
return int(result.content[0].text)
|
|
|
|
return await _recover_new_pid(call, old_pid, **kwargs)
|
|
|
|
|
|
class TestDisconnect:
|
|
async def test_cancelled_connection_task_is_cleaned_up(self):
|
|
transport = StdioTransport(command="python", args=[])
|
|
connect_task = asyncio.create_task(asyncio.sleep(0))
|
|
connect_task.cancel()
|
|
transport._connect_task = connect_task
|
|
|
|
await transport.disconnect()
|
|
|
|
assert transport._connect_task is None
|
|
assert not transport._stop_event.is_set()
|
|
|
|
async def test_caller_cancellation_is_not_suppressed(self):
|
|
transport = StdioTransport(command="python", args=[])
|
|
connection_finished = asyncio.Event()
|
|
connect_task = asyncio.create_task(connection_finished.wait())
|
|
transport._connect_task = connect_task
|
|
|
|
disconnect_task = asyncio.create_task(transport.disconnect())
|
|
await asyncio.sleep(0)
|
|
disconnect_task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await disconnect_task
|
|
assert not connect_task.cancelled()
|
|
|
|
connection_finished.set()
|
|
await connect_task
|
|
await transport.disconnect()
|
|
|
|
async def test_caller_cancellation_wins_when_connection_is_also_cancelled(self):
|
|
transport = StdioTransport(command="python", args=[])
|
|
connect_task = asyncio.create_task(asyncio.Event().wait())
|
|
transport._connect_task = connect_task
|
|
|
|
disconnect_task = asyncio.create_task(transport.disconnect())
|
|
await asyncio.sleep(0)
|
|
connect_task.cancel()
|
|
disconnect_task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await disconnect_task
|
|
|
|
|
|
class TestParallelCalls:
|
|
@pytest.fixture
|
|
def stdio_script(self):
|
|
return MINIMAL_STDIO_SERVER
|
|
|
|
async def test_parallel_calls(self, stdio_script):
|
|
from fastmcp.server import create_proxy
|
|
|
|
backend_transport = PythonStdioTransport(script_path=stdio_script)
|
|
backend_client = Client(transport=backend_transport)
|
|
|
|
proxy = create_proxy(backend_client, name="PROXY")
|
|
|
|
count = 10
|
|
|
|
tasks = [proxy.list_tools() for _ in range(count)]
|
|
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
assert len(results) == count
|
|
errors = [result for result in results if isinstance(result, Exception)]
|
|
assert len(errors) == 0
|
|
|
|
|
|
@pytest.mark.timeout(15)
|
|
class TestKeepAlive:
|
|
# https://github.com/PrefectHQ/fastmcp/issues/581
|
|
|
|
@pytest.fixture
|
|
def stdio_script(self):
|
|
return MINIMAL_STDIO_SERVER
|
|
|
|
async def test_keep_alive_default_true(self):
|
|
client = Client(transport=StdioTransport(command="python", args=[""]))
|
|
|
|
assert client.transport.keep_alive is True
|
|
|
|
async def test_keep_alive_set_false(self):
|
|
client = Client(
|
|
transport=StdioTransport(command="python", args=[""], keep_alive=False)
|
|
)
|
|
assert client.transport.keep_alive is False
|
|
|
|
async def test_keep_alive_maintains_session_across_multiple_calls(
|
|
self, stdio_script
|
|
):
|
|
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
|
|
assert client.transport.keep_alive is True
|
|
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
pid1: int = result1.data
|
|
|
|
async with client:
|
|
result2 = await client.call_tool("pid")
|
|
pid2: int = result2.data
|
|
|
|
assert pid1 == pid2
|
|
|
|
@pytest.mark.skipif(
|
|
running_under_debugger(), reason="Debugger holds a reference to the transport"
|
|
)
|
|
async def test_keep_alive_true_exit_scope_kills_transport(self, stdio_script):
|
|
transport_weak_ref: weakref.ref[PythonStdioTransport] | None = None
|
|
|
|
async def test_server():
|
|
transport = PythonStdioTransport(script_path=stdio_script, keep_alive=True)
|
|
nonlocal transport_weak_ref
|
|
transport_weak_ref = weakref.ref(transport)
|
|
async with transport.connect_session():
|
|
pass
|
|
|
|
await test_server()
|
|
|
|
gc_collect_harder()
|
|
|
|
# This test will fail while debugging because the debugger holds a reference to the underlying transport
|
|
assert transport_weak_ref
|
|
transport = transport_weak_ref()
|
|
assert transport is None
|
|
|
|
@pytest.mark.skipif(
|
|
running_under_debugger(), reason="Debugger holds a reference to the transport"
|
|
)
|
|
async def test_keep_alive_true_exit_scope_kills_client(self, stdio_script):
|
|
pid: int | None = None
|
|
|
|
async def test_server():
|
|
transport = PythonStdioTransport(script_path=stdio_script, keep_alive=True)
|
|
client = Client(transport=transport)
|
|
|
|
assert client.transport.keep_alive is True
|
|
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
nonlocal pid
|
|
pid = result1.data
|
|
|
|
await test_server()
|
|
|
|
gc_collect_harder()
|
|
|
|
# This test may fail/hang while debugging because the debugger holds a reference to the underlying transport
|
|
|
|
await wait_for_process_exit(pid)
|
|
|
|
async def test_keep_alive_false_exit_scope_kills_server(self, stdio_script):
|
|
pid: int | None = None
|
|
|
|
async def test_server():
|
|
transport = PythonStdioTransport(script_path=stdio_script, keep_alive=False)
|
|
client = Client(transport=transport)
|
|
assert client.transport.keep_alive is False
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
nonlocal pid
|
|
pid = result1.data
|
|
|
|
del client
|
|
|
|
await test_server()
|
|
|
|
await wait_for_process_exit(pid)
|
|
|
|
async def test_keep_alive_false_starts_new_session_across_multiple_calls(
|
|
self, stdio_script
|
|
):
|
|
client = Client(
|
|
transport=PythonStdioTransport(script_path=stdio_script, keep_alive=False)
|
|
)
|
|
assert client.transport.keep_alive is False
|
|
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
pid1: int = result1.data
|
|
|
|
async with client:
|
|
result2 = await client.call_tool("pid")
|
|
pid2: int = result2.data
|
|
|
|
assert pid1 != pid2
|
|
|
|
async def test_keep_alive_starts_new_session_if_manually_closed(self, stdio_script):
|
|
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
|
|
assert client.transport.keep_alive is True
|
|
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
pid1: int = result1.data
|
|
|
|
await client.close()
|
|
|
|
async with client:
|
|
result2 = await client.call_tool("pid")
|
|
pid2: int = result2.data
|
|
|
|
assert pid1 != pid2
|
|
|
|
async def test_keep_alive_maintains_session_if_reentered(self, stdio_script):
|
|
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
|
|
assert client.transport.keep_alive is True
|
|
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
pid1: int = result1.data
|
|
|
|
async with client:
|
|
result2 = await client.call_tool("pid")
|
|
pid2: int = result2.data
|
|
|
|
result3 = await client.call_tool("pid")
|
|
pid3: int = result3.data
|
|
|
|
assert pid1 == pid2 == pid3
|
|
|
|
async def test_close_session_and_try_to_use_client_raises_error(self, stdio_script):
|
|
client = Client(transport=PythonStdioTransport(script_path=stdio_script))
|
|
assert client.transport.keep_alive is True
|
|
|
|
async with client:
|
|
await client.close()
|
|
with pytest.raises(RuntimeError, match="Client is not connected"):
|
|
await client.call_tool("pid")
|
|
|
|
async def test_session_task_failure_raises_immediately_on_enter(self):
|
|
# Use a command that will fail to start
|
|
client = Client(
|
|
transport=StdioTransport(command="nonexistent_command", args=[])
|
|
)
|
|
|
|
# Should raise RuntimeError immediately, not defer until first use
|
|
with pytest.raises(RuntimeError, match="Client failed to connect"):
|
|
async with client:
|
|
pass
|
|
|
|
|
|
@pytest.mark.timeout(15)
|
|
class TestSubprocessCrashRecovery:
|
|
"""Test that StdioTransport recovers after the subprocess crashes."""
|
|
|
|
# Use a short init_timeout so tests fail fast instead of hanging if
|
|
# stream-based dead-session detection is slow (e.g. on Windows where
|
|
# pipe cleanup can lag after process termination).
|
|
INIT_TIMEOUT = 3
|
|
|
|
@pytest.fixture
|
|
def stdio_script(self):
|
|
return MINIMAL_STDIO_SERVER
|
|
|
|
async def test_keep_alive_recovers_after_subprocess_crash(self, stdio_script):
|
|
"""When keep_alive=True and the subprocess dies, the next connection should start a fresh subprocess."""
|
|
transport = PythonStdioTransport(script_path=stdio_script)
|
|
client = Client(transport=transport, init_timeout=self.INIT_TIMEOUT)
|
|
assert transport.keep_alive is True
|
|
|
|
# First connection: get the PID of the subprocess
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
pid1: int = result1.data
|
|
|
|
# Kill the subprocess to simulate a crash
|
|
psutil.Process(pid1).kill()
|
|
|
|
# Recovery is transparent: reconnecting eventually lands on a fresh
|
|
# subprocess with a new pid, regardless of how many attempts the
|
|
# stale-session teardown costs.
|
|
pid2 = await recover_client_pid(client, pid1)
|
|
|
|
assert pid1 != pid2
|
|
|
|
async def test_keep_alive_false_recovers_after_subprocess_crash(self, stdio_script):
|
|
"""When keep_alive=False, crash recovery works because disconnect() is always called."""
|
|
client = Client(
|
|
transport=PythonStdioTransport(script_path=stdio_script, keep_alive=False),
|
|
init_timeout=self.INIT_TIMEOUT,
|
|
)
|
|
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
pid1: int = result1.data
|
|
|
|
# Process should already be dead (keep_alive=False), but kill to be sure
|
|
with pytest.raises(psutil.NoSuchProcess):
|
|
psutil.Process(pid1).kill()
|
|
|
|
# Next connection should work fine
|
|
async with client:
|
|
result2 = await client.call_tool("pid")
|
|
pid2: int = result2.data
|
|
|
|
assert pid1 != pid2
|
|
|
|
async def test_multiple_consecutive_crashes(self, stdio_script):
|
|
"""Recovery works across multiple crash/reconnect cycles."""
|
|
client = Client(
|
|
transport=PythonStdioTransport(script_path=stdio_script),
|
|
init_timeout=self.INIT_TIMEOUT,
|
|
)
|
|
pids: list[int] = []
|
|
|
|
for _ in range(3):
|
|
if pids:
|
|
# After a crash, recovery is transparent — the next working
|
|
# call lands on a fresh subprocess with a new pid.
|
|
pid = await recover_client_pid(client, pids[-1])
|
|
else:
|
|
async with client:
|
|
result = await client.call_tool("pid")
|
|
pid = result.data
|
|
pids.append(pid)
|
|
|
|
# Kill the subprocess to force the next cycle to recover
|
|
psutil.Process(pid).kill()
|
|
|
|
# Each cycle should have started a new subprocess
|
|
assert len(set(pids)) == 3
|
|
|
|
async def test_crash_during_active_context(self, stdio_script):
|
|
"""When subprocess dies while the client context is open, recovery works on the next attempt."""
|
|
client = Client(
|
|
transport=PythonStdioTransport(script_path=stdio_script),
|
|
init_timeout=self.INIT_TIMEOUT,
|
|
)
|
|
pid1: int = 0
|
|
|
|
try:
|
|
async with client:
|
|
result = await client.call_tool("pid")
|
|
pid1 = result.data
|
|
# Kill while the context is still open
|
|
psutil.Process(pid1).kill()
|
|
# This call races the asynchronous kill: it may hit the dead
|
|
# session and raise, or briefly still be served. Either outcome
|
|
# is fine — what matters is that recovery works afterward.
|
|
await client.call_tool("pid")
|
|
except CRASH_RECOVERY_EXCEPTIONS:
|
|
pass
|
|
|
|
assert pid1 != 0, "First call should have succeeded before the crash"
|
|
|
|
# Recovery: next connection starts a fresh subprocess
|
|
pid2 = await recover_client_pid(client, pid1)
|
|
|
|
assert pid1 != pid2
|
|
|
|
async def test_proxy_recovers_after_stdio_crash(self, stdio_script):
|
|
"""A proxy server wrapping a stdio backend recovers after the backend crashes."""
|
|
from fastmcp.server import create_proxy
|
|
|
|
backend_client = Client(
|
|
transport=PythonStdioTransport(script_path=stdio_script),
|
|
init_timeout=self.INIT_TIMEOUT,
|
|
)
|
|
proxy = create_proxy(target=backend_client, name="test-proxy")
|
|
|
|
# First call works
|
|
result1 = await proxy.call_tool("pid")
|
|
pid1 = int(result1.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
|
|
# Kill the backend subprocess
|
|
psutil.Process(pid1).kill()
|
|
|
|
# Recovery is transparent: a call after the crash eventually succeeds on
|
|
# a fresh backend subprocess with a new pid.
|
|
pid2 = await recover_proxy_pid(proxy, pid1)
|
|
|
|
assert pid1 != pid2
|
|
|
|
async def test_concurrent_requests_during_crash(self, stdio_script):
|
|
"""Multiple concurrent callers fail cleanly when subprocess dies, then recovery works."""
|
|
from fastmcp.server import create_proxy
|
|
|
|
backend_client = Client(
|
|
transport=PythonStdioTransport(script_path=stdio_script),
|
|
init_timeout=self.INIT_TIMEOUT,
|
|
)
|
|
proxy = create_proxy(target=backend_client, name="test-proxy")
|
|
|
|
# First call to get the PID
|
|
result = await proxy.call_tool("pid")
|
|
pid1 = int(result.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
|
|
# Kill the subprocess
|
|
psutil.Process(pid1).kill()
|
|
|
|
# Fire several concurrent requests. Depending on how quickly the dead
|
|
# session is detected and a replacement spawned, each caller either
|
|
# fails cleanly or lands on the fresh subprocess — with a fast-starting
|
|
# server, recovery can beat all five requests and the crash is fully
|
|
# transparent. What must never happen: a hang (gather returning is the
|
|
# proof), or a "success" served by the killed process.
|
|
tasks = [proxy.call_tool("pid") for _ in range(5)]
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
for r in results:
|
|
if not isinstance(r, Exception):
|
|
served_by = int(r.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
assert served_by != pid1, (
|
|
"call reported success from the killed subprocess"
|
|
)
|
|
|
|
# Recovery: a subsequent request must succeed on a fresh subprocess
|
|
result = await proxy.call_tool("pid")
|
|
pid2 = int(result.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
|
|
assert pid1 != pid2
|
|
|
|
async def test_clean_exit_recovers(self):
|
|
"""Recovery works when the subprocess exits cleanly (exit code 0), not just crashes."""
|
|
client = Client(
|
|
transport=PythonStdioTransport(
|
|
script_path=MINIMAL_STDIO_SERVER,
|
|
args=["--exit-after-calls", "2"],
|
|
),
|
|
init_timeout=self.INIT_TIMEOUT,
|
|
)
|
|
|
|
async with client:
|
|
result1 = await client.call_tool("pid")
|
|
pid1: int = result1.data
|
|
# Second call triggers delayed clean exit
|
|
await client.call_tool("pid")
|
|
|
|
# Wait for the subprocess to actually exit (it self-terminates
|
|
# via a background timer ~0.1s after the second call) instead
|
|
# of blindly sleeping past the worst case.
|
|
await wait_for_process_exit(pid1)
|
|
|
|
# Recovery after clean exit.
|
|
#
|
|
# The transport only notices a dead session once the SDK dispatcher's
|
|
# read loop has observed EOF on the subprocess's stdout and set its
|
|
# `_closed` flag (see `StdioTransport._is_session_dead`). The process
|
|
# being gone does not imply that detection has happened yet: EOF has to
|
|
# travel from the OS pipe through anyio's stream plumbing and then be
|
|
# picked up by a separate read-loop task. On a loaded machine — notably
|
|
# Windows CI running xdist workers on two cores — that can land after
|
|
# `connect()` samples the flag, so the first attempt is routed to the
|
|
# stale session and fails with CONNECTION_CLOSED, which in turn tears
|
|
# the session down so the next attempt reconnects.
|
|
#
|
|
# Like the crash tests above, this asserts eventual recovery rather than
|
|
# a fixed number of failed attempts: the failure is timing-dependent, so
|
|
# retry instead. The invariant under test is that a cleanly-exited server
|
|
# is replaced by a fresh subprocess, not how many attempts EOF detection
|
|
# costs.
|
|
pid2: int | None = None
|
|
for _ in range(2):
|
|
try:
|
|
async with client:
|
|
result2 = await client.call_tool("pid")
|
|
pid2 = result2.data
|
|
break
|
|
except (MCPError, RuntimeError):
|
|
continue
|
|
|
|
assert pid2 is not None, "Client did not recover after a clean subprocess exit"
|
|
assert pid1 != pid2
|
|
|
|
async def test_crash_during_initialization(self, tmp_path):
|
|
"""Recovery works when subprocess crashes during the first connection attempt."""
|
|
# Script that exits immediately — crashes before init completes
|
|
crash_script = tmp_path / "crash_init.py"
|
|
crash_script.write_text(
|
|
inspect.cleandoc("""
|
|
import sys
|
|
sys.exit(1)
|
|
""")
|
|
)
|
|
|
|
client = Client(
|
|
transport=PythonStdioTransport(script_path=crash_script),
|
|
init_timeout=self.INIT_TIMEOUT,
|
|
)
|
|
|
|
with pytest.raises(Exception):
|
|
async with client:
|
|
pass
|
|
|
|
# Replace the same path with a working server. It delegates to the
|
|
# minimal stdio server so the retry doesn't pay for a fastmcp import.
|
|
crash_script.write_text(
|
|
inspect.cleandoc(f"""
|
|
import runpy
|
|
|
|
runpy.run_path({str(MINIMAL_STDIO_SERVER)!r}, run_name="__main__")
|
|
""")
|
|
)
|
|
|
|
# Recovery with the now-working script
|
|
async with client:
|
|
result = await client.call_tool("pid")
|
|
assert isinstance(result.data, int)
|
|
|
|
|
|
@pytest.mark.subprocess_heavy
|
|
class TestLogFile:
|
|
"""Stderr capture, proven against a real FastMCP server.
|
|
|
|
Unlike the rest of this module these spawn a full `import fastmcp`
|
|
interpreter rather than the minimal stdlib server, because the point is
|
|
that the log file captures a real server's stderr. That costs ~0.7s per
|
|
spawn, so they run in the serial CI step.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def stdio_script_with_stderr(self, tmp_path):
|
|
script = inspect.cleandoc('''
|
|
import sys
|
|
from fastmcp import FastMCP
|
|
|
|
mcp = FastMCP()
|
|
|
|
@mcp.tool
|
|
def write_error(message: str) -> str:
|
|
"""Writes a message to stderr and returns it"""
|
|
print(message, file=sys.stderr, flush=True)
|
|
return message
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run()
|
|
''')
|
|
script_file = tmp_path / "stderr_script.py"
|
|
script_file.write_text(script)
|
|
return script_file
|
|
|
|
async def test_log_file_parameter_accepted_by_stdio_transport(self, tmp_path):
|
|
"""Test that log_file parameter can be set on StdioTransport"""
|
|
log_file_path = tmp_path / "errors.log"
|
|
transport = StdioTransport(
|
|
command="python", args=["script.py"], log_file=log_file_path
|
|
)
|
|
assert transport.log_file == log_file_path
|
|
|
|
async def test_log_file_parameter_accepted_by_python_stdio_transport(
|
|
self, tmp_path, stdio_script_with_stderr
|
|
):
|
|
"""Test that log_file parameter can be set on PythonStdioTransport"""
|
|
log_file_path = tmp_path / "errors.log"
|
|
transport = PythonStdioTransport(
|
|
script_path=stdio_script_with_stderr, log_file=log_file_path
|
|
)
|
|
assert transport.log_file == log_file_path
|
|
|
|
async def test_log_file_parameter_accepts_textio(self, tmp_path):
|
|
"""Test that log_file parameter can accept a TextIO object"""
|
|
log_file_path = tmp_path / "errors.log"
|
|
with open(log_file_path, "w") as log_file:
|
|
transport = StdioTransport(
|
|
command="python", args=["script.py"], log_file=log_file
|
|
)
|
|
assert transport.log_file == log_file
|
|
|
|
async def test_log_file_captures_stderr_output_with_path(
|
|
self, tmp_path, stdio_script_with_stderr
|
|
):
|
|
"""Test that stderr output is written to the log_file when using Path"""
|
|
log_file_path = tmp_path / "errors.log"
|
|
|
|
transport = PythonStdioTransport(
|
|
script_path=stdio_script_with_stderr, log_file=log_file_path
|
|
)
|
|
client = Client(transport=transport)
|
|
|
|
async with client:
|
|
await client.call_tool("write_error", {"message": "Test error message"})
|
|
|
|
content = await wait_for_log_content(log_file_path, "Test error message")
|
|
assert "Test error message" in content
|
|
|
|
async def test_log_file_captures_stderr_output_with_textio(
|
|
self, tmp_path, stdio_script_with_stderr
|
|
):
|
|
"""Test that stderr output is written to the log_file when using TextIO"""
|
|
log_file_path = tmp_path / "errors.log"
|
|
|
|
with open(log_file_path, "w") as log_file:
|
|
transport = PythonStdioTransport(
|
|
script_path=stdio_script_with_stderr, log_file=log_file
|
|
)
|
|
client = Client(transport=transport)
|
|
|
|
async with client:
|
|
await client.call_tool(
|
|
"write_error", {"message": "Test error with TextIO"}
|
|
)
|
|
|
|
content = await wait_for_log_content(
|
|
log_file_path, "Test error with TextIO"
|
|
)
|
|
|
|
assert "Test error with TextIO" in content
|
|
|
|
async def test_log_file_none_uses_default_behavior(
|
|
self, tmp_path, stdio_script_with_stderr
|
|
):
|
|
"""Test that log_file=None uses default stderr handling"""
|
|
transport = PythonStdioTransport(
|
|
script_path=stdio_script_with_stderr, log_file=None
|
|
)
|
|
client = Client(transport=transport)
|
|
|
|
async with client:
|
|
# Should work without error even without explicit log_file
|
|
result = await client.call_tool(
|
|
"write_error", {"message": "Default stderr"}
|
|
)
|
|
assert result.data == "Default stderr"
|