From 2390fb4da669df839ae6e57920a4662cfc48a234 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:32:08 -0400 Subject: [PATCH 1/5] clean up test inits --- tests/auth/__init__.py | 0 tests/cli/__init__.py | 0 tests/client/__init__.py | 1 - tests/server/http/__init__.py | 0 tests/server/openapi/__init__.py | 0 tests/server/test_lifespan.py | 396 ------------------------------- 6 files changed, 397 deletions(-) create mode 100644 tests/auth/__init__.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/server/http/__init__.py create mode 100644 tests/server/openapi/__init__.py delete mode 100644 tests/server/test_lifespan.py diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/client/__init__.py b/tests/client/__init__.py index 92836662d..e69de29bb 100644 --- a/tests/client/__init__.py +++ b/tests/client/__init__.py @@ -1 +0,0 @@ -"""Client tests package.""" diff --git a/tests/server/http/__init__.py b/tests/server/http/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/openapi/__init__.py b/tests/server/openapi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py deleted file mode 100644 index ad041bbf9..000000000 --- a/tests/server/test_lifespan.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Tests for lifespan functionality in both low-level and FastMCP servers.""" - -import os -import sys -import traceback -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from pathlib import Path - -import anyio -import httpx -import uvicorn -from mcp.server.lowlevel.server import NotificationOptions, Server -from mcp.server.models import InitializationOptions -from mcp.shared.message import SessionMessage -from mcp.types import ( - ClientCapabilities, - Implementation, - InitializeRequestParams, - JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, -) -from pydantic import TypeAdapter -from starlette.applications import Starlette -from starlette.routing import Mount - -from fastmcp import Context, FastMCP -from fastmcp.utilities.tests import run_server_in_process - - -async def test_lowlevel_server_lifespan(): - """Test that lifespan works in low-level server.""" - - @asynccontextmanager - async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]: - """Test lifespan context that tracks startup/shutdown.""" - context = {"started": False, "shutdown": False} - try: - context["started"] = True - yield context - finally: - context["shutdown"] = True - - server = Server("test", lifespan=test_lifespan) - - # Create memory streams for testing - send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) - send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) - - # Create a tool that accesses lifespan context - @server.call_tool() - async def check_lifespan(name: str, arguments: dict) -> list: - ctx = server.request_context - assert isinstance(ctx.lifespan_context, dict) - assert ctx.lifespan_context["started"] - assert not ctx.lifespan_context["shutdown"] - return [{"type": "text", "text": "true"}] - - # Run server in background task - async with ( - anyio.create_task_group() as tg, - send_stream1, - receive_stream1, - send_stream2, - receive_stream2, - ): - - async def run_server(): - await server.run( - receive_stream1, - send_stream2, - InitializationOptions( - server_name="test", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - raise_exceptions=True, - ) - - tg.start_soon(run_server) - - # Initialize the server - params = InitializeRequestParams( - protocolVersion="2024-11-05", - capabilities=ClientCapabilities(), - clientInfo=Implementation(name="test-client", version="0.1.0"), - ) - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), - ) - ) - ) - ) - response = await receive_stream2.receive() - response = response.message - - # Send initialized notification - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) - ) - - # Call the tool to verify lifespan context - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, - ) - ) - ) - ) - - # Get response and verify - response = await receive_stream2.receive() - response = response.message - assert response.root.result["content"][0]["text"] == "true" - - # Cancel server task - tg.cancel_scope.cancel() - - -async def test_fastmcp_server_lifespan(): - """Test that lifespan works in FastMCP server.""" - - @asynccontextmanager - async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]: - """Test lifespan context that tracks startup/shutdown.""" - context = {"started": False, "shutdown": False} - try: - context["started"] = True - yield context - finally: - context["shutdown"] = True - - server = FastMCP("test", lifespan=test_lifespan) - - # Create memory streams for testing - send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) - send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) - - # Add a tool that checks lifespan context - @server.tool() - def check_lifespan(ctx: Context) -> bool: - """Tool that checks lifespan context.""" - assert isinstance(ctx.request_context.lifespan_context, dict) - assert ctx.request_context.lifespan_context["started"] - assert not ctx.request_context.lifespan_context["shutdown"] - return True - - # Run server in background task - async with ( - anyio.create_task_group() as tg, - send_stream1, - receive_stream1, - send_stream2, - receive_stream2, - ): - - async def run_server(): - await server._mcp_server.run( - receive_stream1, - send_stream2, - server._mcp_server.create_initialization_options(), - raise_exceptions=True, - ) - - tg.start_soon(run_server) - - # Initialize the server - params = InitializeRequestParams( - protocolVersion="2024-11-05", - capabilities=ClientCapabilities(), - clientInfo=Implementation(name="test-client", version="0.1.0"), - ) - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), - ) - ) - ) - ) - response = await receive_stream2.receive() - response = response.message - - # Send initialized notification - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) - ) - - # Call the tool to verify lifespan context - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, - ) - ) - ) - ) - - # Get response and verify - response = await receive_stream2.receive() - response = response.message - assert response.root.result["content"][0]["text"] == "true" - - # Cancel server task - tg.cancel_scope.cancel() - - -def run_server_with_incorrect_lifespan_setup( - host: str, port: int, server_log_file_path: str -) -> None: - os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True) - - CUSTOM_LOGGING_CONFIG = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "()": "uvicorn.logging.DefaultFormatter", - "fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s", - "datefmt": "%Y-%m-%d %H:%M:%S", - "use_colors": False, - }, - "access": { - "()": "uvicorn.logging.AccessFormatter", - "fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s', - "datefmt": "%Y-%m-%d %H:%M:%S", - "use_colors": False, - }, - }, - "handlers": { - "file_default": { - "formatter": "default", - "class": "logging.FileHandler", - "filename": server_log_file_path, - "mode": "w", - }, - "file_access": { - "formatter": "access", - "class": "logging.FileHandler", - "filename": server_log_file_path, - "mode": "a", - }, - }, - "loggers": { - "uvicorn": { # Catches uvicorn root logs - "handlers": ["file_default"], - "level": "DEBUG", - "propagate": False, - }, - "uvicorn.error": { - "handlers": ["file_default"], - "level": "DEBUG", - "propagate": False, - }, - "uvicorn.access": { - "handlers": ["file_access"], - "level": "INFO", - "propagate": False, - }, - }, - "root": { - "handlers": ["file_default"], - "level": "DEBUG", - }, - } - - try: - mcp = FastMCP() - - @mcp.tool("ping_tool", "A simple ping tool for the test server") - def ping_tool() -> str: - return "pong" - - mcp_asgi_app = mcp.http_app(transport="streamable-http") - - parent_app = Starlette( - routes=[Mount("/mounted_mcp", app=mcp_asgi_app)], - ) - - uvicorn.run( - parent_app, - host=host, - port=port, - log_config=CUSTOM_LOGGING_CONFIG, - log_level=None, - ) - sys.exit(0) - except Exception as e_outer: - with open(server_log_file_path, "a") as f_fallback: - f_fallback.write( - "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n" - ) - f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n") - f_fallback.write(traceback.format_exc()) - sys.exit(1) - - -async def test_missing_lifespan_logs_informative_error(tmp_path: Path): - server_log_file = tmp_path / "server.log" - - with run_server_in_process( - run_server_with_incorrect_lifespan_setup, str(server_log_file) - ) as server_url: - full_mcp_path = server_url + "/mounted_mcp/mcp/" - - client_triggered_error = False - response_status = -1 - response_body = "" - try: - async with httpx.AsyncClient(timeout=10) as client: - response = await client.post( - full_mcp_path, - json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"}, - ) - response_status = response.status_code - response_body = response.text - if response.status_code == 500: - client_triggered_error = True - else: - print( - f"Client received unexpected status code: {response.status_code} " - f"Response: {response_body[:500]}" - ) - except httpx.RequestError as e: - print(f"Client request failed with RequestError: {e}") - client_triggered_error = True - - assert client_triggered_error, ( - f"Client request did not result in a 500 error or a request error. " - f"Status: {response_status}, Body: {response_body[:500]}" - ) - - assert server_log_file.exists(), ( - f"Server log file was not created at {server_log_file}" - ) - log_content = server_log_file.read_text() - - print(f"--- Captured Server Log Content ({server_log_file}) ---") - print(log_content) - print("--- End Server Log Content ---") - - # Core assertions for the enhanced error message - assert ( - "FastMCP's StreamableHTTPSessionManager task group was not initialized" - in log_content - ) - assert "lifespan=mcp_app.lifespan" in log_content - assert "gofastmcp.com/deployment/asgi" in log_content - assert "Original error: Task group is not initialized" in log_content - - # Check for Uvicorn's own error logging wrapper for the request - assert "ERROR" in log_content # General check for ERROR level logs - assert "Exception in ASGI application" in log_content - - # Sanity checks for server operation and logging setup - assert "Uvicorn running on" in log_content - assert ( - "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content - ) From ad8182eed0361925e542fb48a092747569ed96ef Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:33:50 -0400 Subject: [PATCH 2/5] Create http utility --- src/fastmcp/client/auth.py | 2 +- src/fastmcp/client/oauth_callback.py | 9 +-------- src/fastmcp/server/auth/auth.py | 10 ++++++++++ src/fastmcp/utilities/http.py | 8 ++++++++ .../test_oauth.py => auth/test_oauth_client.py} | 4 ++-- 5 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 src/fastmcp/utilities/http.py rename tests/{client/test_oauth.py => auth/test_oauth_client.py} (98%) diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index 6165fdfd8..43df9d442 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -25,9 +25,9 @@ from pydantic import AnyHttpUrl, ValidationError from fastmcp.client.oauth_callback import ( create_oauth_callback_server, - find_available_port, ) from fastmcp.settings import settings as fastmcp_global_settings +from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger __all__ = ["OAuth"] diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index f9cecd16b..891e4cdb0 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -8,7 +8,6 @@ and display styled responses to users. from __future__ import annotations import asyncio -import socket from dataclasses import dataclass from starlette.applications import Starlette @@ -17,6 +16,7 @@ from starlette.responses import HTMLResponse from starlette.routing import Route from uvicorn import Config, Server +from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -179,13 +179,6 @@ def create_callback_html( """ -def find_available_port() -> int: - """Find an available port by letting the OS assign one.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - @dataclass class CallbackResponse: code: str | None = None diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index b92160304..b5f07c523 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -23,6 +23,16 @@ class OAuthProvider( revocation_options: RevocationOptions | None = None, required_scopes: list[str] | None = None, ): + """ + Initialize the OAuth provider. + + Args: + issuer_url: The URL of the OAuth issuer. + service_documentation_url: The URL of the service documentation. + client_registration_options: The client registration options. + revocation_options: The revocation options. + required_scopes: Scopes that are required for all requests. + """ super().__init__() if isinstance(issuer_url, str): issuer_url = AnyHttpUrl(issuer_url) diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py new file mode 100644 index 000000000..22c165735 --- /dev/null +++ b/src/fastmcp/utilities/http.py @@ -0,0 +1,8 @@ +import socket + + +def find_available_port() -> int: + """Find an available port by letting the OS assign one.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] diff --git a/tests/client/test_oauth.py b/tests/auth/test_oauth_client.py similarity index 98% rename from tests/client/test_oauth.py rename to tests/auth/test_oauth_client.py index 1c2488ec8..e743e25c3 100644 --- a/tests/client/test_oauth.py +++ b/tests/auth/test_oauth_client.py @@ -11,7 +11,7 @@ import fastmcp.client.auth # Import module, not the function directly from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions -from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider +from fastmcp.server.auth.providers.in_memory import InMemory from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process @@ -20,7 +20,7 @@ def fastmcp_server(issuer_url: str): """Create a FastMCP server with OAuth authentication.""" server = FastMCP( "TestServer", - auth=InMemoryOAuthProvider( + auth=InMemory( issuer_url=issuer_url, client_registration_options=ClientRegistrationOptions(enabled=True), ), From 3d454380b15f52aa7626f6a3e8974eb7379be5a4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:36:04 -0400 Subject: [PATCH 3/5] Fix import and remaining available port --- src/fastmcp/utilities/tests.py | 5 ++--- tests/auth/test_oauth_client.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index cb697129f..4fa73006e 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal import uvicorn from fastmcp.settings import settings +from fastmcp.utilities.http import find_available_port if TYPE_CHECKING: from fastmcp.server.server import FastMCP @@ -84,9 +85,7 @@ def run_server_in_process( The server URL. """ host = "127.0.0.1" - with socket.socket() as s: - s.bind((host, 0)) - port = s.getsockname()[1] + port = find_available_port() proc = multiprocessing.Process( target=server_fn, args=(host, port, *args), daemon=True diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index e743e25c3..2a1fd4c23 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -11,7 +11,7 @@ import fastmcp.client.auth # Import module, not the function directly from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions -from fastmcp.server.auth.providers.in_memory import InMemory +from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider as InMemory from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process From 97c9b9cbe44979cd7847a0e8de8fe43bf246643a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:30:36 -0400 Subject: [PATCH 4/5] Fix import --- src/fastmcp/client/transports.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index cafe9588c..5ab1d9a9e 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -37,7 +37,6 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.client.auth import OAuth -from fastmcp.server import FastMCP as FastMCPServer from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger @@ -55,7 +54,6 @@ __all__ = [ "ClientTransport", "SSETransport", "StreamableHttpTransport", - "FastMCPServer", "StdioTransport", "PythonStdioTransport", "FastMCPStdioTransport", @@ -656,7 +654,7 @@ class FastMCPTransport(ClientTransport): tests or scenarios where client and server run in the same runtime. """ - def __init__(self, mcp: FastMCPServer | FastMCP1Server): + def __init__(self, mcp: FastMCP | FastMCP1Server): """Initialize a FastMCPTransport from a FastMCP server instance.""" # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a @@ -770,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ... @overload -def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ... +def infer_transport(transport: FastMCP) -> FastMCPTransport: ... @overload @@ -805,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor def infer_transport( transport: ClientTransport - | FastMCPServer + | FastMCP | FastMCP1Server | AnyUrl | Path @@ -822,7 +820,7 @@ def infer_transport( The function supports these input types: - ClientTransport: Used directly without modification - - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport + - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js) - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints) - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers @@ -860,7 +858,7 @@ def infer_transport( return transport # the transport is a FastMCP server (2.x or 1.0) - elif isinstance(transport, FastMCPServer | FastMCP1Server): + elif isinstance(transport, FastMCP | FastMCP1Server): inferred_transport = FastMCPTransport(mcp=transport) # the transport is a path to a script From 9cc67eeb70e895670df484e5a09112e0ac4448c2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:34:14 -0400 Subject: [PATCH 5/5] Update src/fastmcp/utilities/http.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/utilities/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py index 22c165735..c1237d62e 100644 --- a/src/fastmcp/utilities/http.py +++ b/src/fastmcp/utilities/http.py @@ -3,6 +3,6 @@ import socket def find_available_port() -> int: """Find an available port by letting the OS assign one.""" - with socket.socket() as s: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1]