diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index 9cf63f63d..526844f6c 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -14,13 +14,10 @@ on: - "uv.lock" - "pyproject.toml" - ".github/workflows/**" + + # run on all pull requests because these checks are required and will block merges otherwise pull_request: - paths: - - "src/**" - - "tests/**" - - "uv.lock" - - "pyproject.toml" - - ".github/workflows/**" + workflow_dispatch: permissions: diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 33c3e8e29..c201aca74 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -13,13 +13,9 @@ on: - "uv.lock" - "pyproject.toml" - ".github/workflows/**" + + # run on all pull requests because these checks are required and will block merges otherwise pull_request: - paths: - - "src/**" - - "tests/**" - - "uv.lock" - - "pyproject.toml" - - ".github/workflows/**" workflow_dispatch: @@ -45,18 +41,10 @@ jobs: with: enable-cache: true cache-dependency-glob: "uv.lock" - - - name: Set up Python ${{ matrix.python-version }} - run: uv python install ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} - name: Install FastMCP - run: uv sync --dev - - - name: Fix pyreadline on Windows - if: matrix.os == 'windows-latest' - run: | - uv pip uninstall -y pyreadline - uv pip install pyreadline3 + run: uv sync --dev --locked - name: Run tests - run: uv run --frozen pytest + run: uv run pytest diff --git a/docs/clients/client.mdx b/docs/clients/client.mdx index 1f1e6aa76..5c9607800 100644 --- a/docs/clients/client.mdx +++ b/docs/clients/client.mdx @@ -30,9 +30,8 @@ The following inference rules are used to determine the appropriate `ClientTrans 3. **`Path` or `str` pointing to an existing file**: * If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`. * If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`. -4. **`AnyUrl` or `str` pointing to a URL**: - * If it starts with `http://` or `https://`: Creates an `SSETransport`. - * If it starts with `ws://` or `wss://`: Creates a `WSTransport`. +4. **`AnyUrl` or `str` pointing to a URL that begins with `http://` or `https://`**: + * Creates a `StreamableHttpTransport` 5. **Other**: Raises a `ValueError` if the type cannot be inferred. ```python @@ -41,24 +40,24 @@ from fastmcp import Client, FastMCP # Example transports (more details in Transports page) server_instance = FastMCP(name="TestServer") # In-memory server -sse_url = "http://localhost:8000/sse" # SSE server URL +http_url = "https://example.com/mcp" # HTTP server URL ws_url = "ws://localhost:9000" # WebSocket server URL server_script = "my_mcp_server.py" # Path to a Python server file # Client automatically infers the transport type client_in_memory = Client(server_instance) -client_sse = Client(sse_url) +client_http = Client(http_url) client_ws = Client(ws_url) client_stdio = Client(server_script) print(client_in_memory.transport) -print(client_sse.transport) +print(client_http.transport) print(client_ws.transport) print(client_stdio.transport) # Expected Output (types may vary slightly based on environment): # -# +# # # ``` @@ -115,14 +114,18 @@ The standard client methods return user-friendly representations that may change tools = await client.list_tools() # tools -> list[mcp.types.Tool] ``` -* **`call_tool(name: str, arguments: dict[str, Any] | None = None)`**: Executes a tool on the server. +* **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server. ```python result = await client.call_tool("add", {"a": 5, "b": 3}) # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...] print(result[0].text) # Assuming TextContent, e.g., '8' + + # With timeout (aborts if execution takes longer than 2 seconds) + result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0) ``` * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed. * Returns a list of content objects (usually `TextContent` or `ImageContent`). + * The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout. #### Resource Operations @@ -191,6 +194,45 @@ These methods are especially useful for debugging or when you need to access met MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests. +#### Timeout Control + + + +You can control request timeouts at both the client level and individual request level: + +```python +from fastmcp import Client +from fastmcp.exceptions import McpError + +# Client with a global 5-second timeout for all requests +client = Client( + my_mcp_server, + timeout=5.0 # Default timeout in seconds +) + +async with client: + # This uses the global 5-second timeout + result1 = await client.call_tool("quick_task", {"param": "value"}) + + # This specifies a 10-second timeout for this specific call + result2 = await client.call_tool("slow_task", {"param": "value"}, timeout=10.0) + + try: + # This will likely timeout + result3 = await client.call_tool("medium_task", {"param": "value"}, timeout=0.01) + except McpError as e: + # Handle timeout error + print(f"The task timed out: {e}") +``` + + +Timeout behavior varies between transport types: + +- With **SSE** transport, the per-request (tool call) timeout **always** takes precedence, regardless of which is lower. +- With **HTTP** transport, the **lower** of the two timeouts (client or tool call) takes precedence. + +For consistent behavior across all transports, we recommend explicitly setting timeouts at the individual tool call level when needed, rather than relying on client-level timeouts. + #### LLM Sampling diff --git a/docs/clients/transports.mdx b/docs/clients/transports.mdx index 5b0a6935d..2b2b24ec5 100644 --- a/docs/clients/transports.mdx +++ b/docs/clients/transports.mdx @@ -13,6 +13,19 @@ The FastMCP `Client` relies on a `ClientTransport` object to handle the specific While the `Client` often infers the correct transport automatically (see [Client Overview](/clients/client#transport-inference)), you can also instantiate transports explicitly for more control. + +Clients are lightweight objects, so don't hesitate to create new ones as needed. However, be mindful of the context management - each time you open a client context (`async with client:`), a new connection or process starts. For best performance, keep client contexts open while performing multiple operations rather than repeatedly opening and closing them. + + +## Choosing a Transport + +Choose the transport that best fits your use case: + +- **Connecting to Remote/Persistent Servers:** Use `StreamableHttpTransport` (recommended, default for HTTP URLs) or `SSETransport` (legacy option) for web-based deployments. + +- **Local Development/Testing:** Use `FastMCPTransport` for in-memory, same-process testing of your FastMCP servers. + +- **Running Local Servers:** Use `UvxStdioTransport` (Python/uv) or `NpxStdioTransport` (Node/npm) if you need to run MCP servers as packaged tools. ## Network Transports @@ -22,70 +35,122 @@ These transports connect to servers running over a network, typically long-runni -* **Class:** `fastmcp.client.transports.StreamableHttpTransport` -* **Inferred From:** `http://` or `https://` URLs (default for HTTP URLs as of v2.3.0) -* **Use Case:** Connecting to persistent MCP servers exposed over HTTP/S using FastMCP's `mcp.run(transport="streamable-http")` mode. - Streamable HTTP is the recommended transport for web-based deployments, providing efficient bidirectional communication over HTTP. +#### Overview + +- **Class:** `fastmcp.client.transports.StreamableHttpTransport` +- **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0) +- **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode + +#### Basic Usage + +The simplest way to use Streamable HTTP is to let the transport be inferred from a URL: + +```python +from fastmcp import Client +import asyncio + +# The Client automatically uses StreamableHttpTransport for HTTP URLs +client = Client("https://example.com/mcp") + +async def main(): + async with client: + tools = await client.list_tools() + print(f"Available tools: {tools}") + +asyncio.run(main()) +``` + +#### Authentication with Headers + +For servers requiring authentication: + ```python from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport -http_url = "http://localhost:8000/mcp" +# Create transport with authentication headers +transport = StreamableHttpTransport( + url="https://example.com/mcp", + headers={"Authorization": "Bearer your-token-here"} +) -# Option 1: Inferred transport (default for HTTP URLs) -client_inferred = Client(http_url) - -# Option 2: Explicit transport (e.g., to add custom headers) -headers = {"Authorization": "Bearer mytoken"} -transport_explicit = StreamableHttpTransport(url=http_url, headers=headers) -client_explicit = Client(transport_explicit) - -async def use_streamable_http_client(client): - async with client: - tools = await client.list_tools() - print(f"Connected via Streamable HTTP, found tools: {tools}") - -# asyncio.run(use_streamable_http_client(client_inferred)) -# asyncio.run(use_streamable_http_client(client_explicit)) +client = Client(transport) ``` ### SSE (Server-Sent Events) -* **Class:** `fastmcp.client.transports.SSETransport` -* **Inferred From:** Not automatically inferred for most HTTP URLs (as of v2.3.0) -* **Use Case:** Connecting to MCP servers using Server-Sent Events, often using FastMCP's `mcp.run(transport="sse")` mode. + -While SSE is still supported, Streamable HTTP is the recommended transport for new web-based deployments. +Server-Sent Events (SSE) is a transport that allows servers to push data to clients over HTTP connections. While still supported, Streamable HTTP is now the recommended transport for new web-based deployments. + +#### Overview + +- **Class:** `fastmcp.client.transports.SSETransport` +- **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified) +- **Server Compatibility:** Works with FastMCP servers running in `sse` mode + +#### Basic Usage + +Since v2.3.0, you must explicitly create an `SSETransport` for SSE connections: + +```python +from fastmcp import Client +from fastmcp.client.transports import SSETransport +import asyncio + +# Create an SSE transport +transport = SSETransport(url="https://example.com/sse") + +# Pass the transport to the client +client = Client(transport) + +async def main(): + async with client: + tools = await client.list_tools() + print(f"Available tools: {tools}") + +asyncio.run(main()) +``` + +#### Authentication with Headers + +SSE transport also supports custom headers for authentication: ```python from fastmcp import Client from fastmcp.client.transports import SSETransport -sse_url = "http://localhost:8000/sse" +# Create SSE transport with authentication headers +transport = SSETransport( + url="https://example.com/sse", + headers={"Authorization": "Bearer your-token-here"} +) -# Since v2.3.0, HTTP URLs default to StreamableHttpTransport, -# so you must explicitly use SSETransport for SSE connections -transport_explicit = SSETransport(url=sse_url) -client_explicit = Client(transport_explicit) - -async def use_sse_client(client): - async with client: - tools = await client.list_tools() - print(f"Connected via SSE, found tools: {tools}") - -# asyncio.run(use_sse_client(client_explicit)) +client = Client(transport) ``` -## Stdio Transports + +#### When to Use SSE vs. Streamable HTTP + +- **Use Streamable HTTP when:** + - Setting up new deployments (recommended default) + - You need bidirectional streaming + - You're connecting to FastMCP servers running in `streamable-http` mode + +- **Use SSE when:** + - Connecting to legacy FastMCP servers running in `sse` mode + - Working with infrastructure optimized for Server-Sent Events + +## Local Transports These transports manage an MCP server running as a subprocess, communicating with it via standard input (stdin) and standard output (stdout). This is the standard mechanism used by clients like Claude Desktop. ### Python Stdio -* **Class:** `fastmcp.client.transports.PythonStdioTransport` -* **Inferred From:** Paths to `.py` files. -* **Use Case:** Running a Python-based MCP server script (like one using FastMCP or the base `mcp` library) in a subprocess. +- **Class:** `fastmcp.client.transports.PythonStdioTransport` +- **Inferred From:** Paths to `.py` files +- **Use Case:** Running a Python-based MCP server script in a subprocess This is the most common way to interact with local FastMCP servers during development or when integrating with tools that expect to launch a server script. @@ -93,39 +158,37 @@ This is the most common way to interact with local FastMCP servers during develo from fastmcp import Client from fastmcp.client.transports import PythonStdioTransport -server_script = "my_mcp_server.py" # Assumes this file exists and runs mcp.run() +server_script = "my_mcp_server.py" # Path to your server script # Option 1: Inferred transport -client_inferred = Client(server_script) +client = Client(server_script) -# Option 2: Explicit transport (e.g., to use a specific python executable or add args) -transport_explicit = PythonStdioTransport( +# Option 2: Explicit transport with custom configuration +transport = PythonStdioTransport( script_path=server_script, - python_cmd="/usr/bin/python3.11", # Specify python version - # args=["--some-server-arg"], # Pass args to the script - # env={"MY_VAR": "value"}, # Set environment variables - # cwd="/path/to/run/in" # Set working directory + python_cmd="/usr/bin/python3.11", # Optional: specify Python interpreter + # args=["--some-server-arg"], # Optional: pass arguments to the script + # env={"MY_VAR": "value"}, # Optional: set environment variables ) -client_explicit = Client(transport_explicit) +client = Client(transport) -async def use_stdio_client(client): +async def main(): async with client: tools = await client.list_tools() print(f"Connected via Python Stdio, found tools: {tools}") -# asyncio.run(use_stdio_client(client_inferred)) -# asyncio.run(use_stdio_client(client_explicit)) +asyncio.run(main()) ``` -The server script (`my_mcp_server.py` in the example) *must* include logic to start the MCP server and listen on stdio, typically via `mcp.run()` or `fastmcp.server.run()`. The `Client` only launches the script; it doesn't inject the server logic. +The server script must include logic to start the MCP server and listen on stdio, typically via `mcp.run()` or `fastmcp.server.run()`. The Client only launches the script; it doesn't inject the server logic. ### Node.js Stdio -* **Class:** `fastmcp.client.transports.NodeStdioTransport` -* **Inferred From:** Paths to `.js` files. -* **Use Case:** Running a Node.js-based MCP server script in a subprocess. +- **Class:** `fastmcp.client.transports.NodeStdioTransport` +- **Inferred From:** Paths to `.js` files +- **Use Case:** Running a Node.js-based MCP server script in a subprocess Similar to the Python transport, but for JavaScript servers. @@ -133,112 +196,111 @@ Similar to the Python transport, but for JavaScript servers. from fastmcp import Client from fastmcp.client.transports import NodeStdioTransport -node_server_script = "my_mcp_server.js" # Assumes this JS file starts an MCP server on stdio +node_server_script = "my_mcp_server.js" # Path to your Node.js server script # Option 1: Inferred transport -client_inferred = Client(node_server_script) +client = Client(node_server_script) # Option 2: Explicit transport -transport_explicit = NodeStdioTransport( +transport = NodeStdioTransport( script_path=node_server_script, - node_cmd="node" # Or specify path to Node executable + node_cmd="node" # Optional: specify path to Node executable ) -client_explicit = Client(transport_explicit) +client = Client(transport) -# Usage is the same as other clients -# async with client_explicit: -# tools = await client_explicit.list_tools() +async def main(): + async with client: + tools = await client.list_tools() + print(f"Connected via Node.js Stdio, found tools: {tools}") + +asyncio.run(main()) ``` ### UVX Stdio (Experimental) -* **Class:** `fastmcp.client.transports.UvxStdioTransport` -* **Inferred From:** Not automatically inferred. Must be instantiated explicitly. -* **Use Case:** Running an MCP server packaged as a Python tool using [`uvx`](https://docs.astral.sh/uv/reference/cli/#uvx) (part of the `uv` toolchain). This allows running tools without explicitly installing them into the current environment. +- **Class:** `fastmcp.client.transports.UvxStdioTransport` +- **Inferred From:** Not automatically inferred +- **Use Case:** Running an MCP server packaged as a Python tool using [`uvx`](https://docs.astral.sh/uv/reference/cli/#uvx) -This is useful for executing MCP servers distributed as command-line tools or packages. +This is useful for executing MCP servers distributed as command-line tools or packages without installing them into your environment. ```python +from fastmcp import Client from fastmcp.client.transports import UvxStdioTransport -# Example: Run a hypothetical 'cloud-analyzer-mcp' tool via uvx -# Assume this tool, when run, starts an MCP server on stdio +# Run a hypothetical 'cloud-analyzer-mcp' tool via uvx transport = UvxStdioTransport( tool_name="cloud-analyzer-mcp", - # from_package="cloud-analyzer-cli", # Optionally specify package if tool name differs - # with_packages=["boto3", "requests"], # Add dependencies if needed - # tool_args=["--config", "prod.yaml"] # Pass args to the tool itself + # from_package="cloud-analyzer-cli", # Optional: specify package if tool name differs + # with_packages=["boto3", "requests"] # Optional: add dependencies ) client = Client(transport) -# async with client: -# analysis = await client.call_tool("analyze_bucket", {"name": "my-data"}) +async def main(): + async with client: + result = await client.call_tool("analyze_bucket", {"name": "my-data"}) + print(f"Analysis result: {result}") + +asyncio.run(main()) ``` ### NPX Stdio (Experimental) -* **Class:** `fastmcp.client.transports.NpxStdioTransport` -* **Inferred From:** Not automatically inferred. Must be instantiated explicitly. -* **Use Case:** Running an MCP server packaged as an NPM package using `npx`. +- **Class:** `fastmcp.client.transports.NpxStdioTransport` +- **Inferred From:** Not automatically inferred +- **Use Case:** Running an MCP server packaged as an NPM package using `npx` Similar to `UvxStdioTransport`, but for the Node.js ecosystem. ```python +from fastmcp import Client from fastmcp.client.transports import NpxStdioTransport -# Example: Run a hypothetical 'npm-mcp-server-package' via npx +# Run an MCP server from an NPM package transport = NpxStdioTransport( - package="npm-mcp-server-package", - # args=["--port", "stdio"] # Args passed to the package script + package="mcp-server-package", + # args=["--port", "stdio"] # Optional: pass arguments to the package ) client = Client(transport) -# async with client: -# response = await client.call_tool("get_npm_data", {}) +async def main(): + async with client: + result = await client.call_tool("get_npm_data", {}) + print(f"Result: {result}") + +asyncio.run(main()) ``` ## In-Memory Transports ### FastMCP Transport -* **Class:** `fastmcp.client.transports.FastMCPTransport` -* **Inferred From:** An instance of `fastmcp.server.FastMCP`. -* **Use Case:** Connecting directly to a `FastMCP` server instance running in the *same Python process*. +- **Class:** `fastmcp.client.transports.FastMCPTransport` +- **Inferred From:** An instance of `fastmcp.server.FastMCP` +- **Use Case:** Connecting directly to a `FastMCP` server instance in the same Python process -This is extremely useful for: -* **Testing:** Writing unit or integration tests for your FastMCP server without needing subprocesses or network connections. -* **Embedding:** Using an MCP server as a component within a larger application. +This is extremely useful for testing your FastMCP servers. ```python from fastmcp import FastMCP, Client -from fastmcp.client.transports import FastMCPTransport +import asyncio # 1. Create your FastMCP server instance server = FastMCP(name="InMemoryServer") + @server.tool() -def ping(): return "pong" +def ping(): + return "pong" # 2. Create a client pointing directly to the server instance -# Option A: Inferred -client_inferred = Client(server) +client = Client(server) # Transport is automatically inferred -# Option B: Explicit -transport_explicit = FastMCPTransport(mcp=server) -client_explicit = Client(transport_explicit) +async def main(): + async with client: + result = await client.call_tool("ping") + print(f"In-memory call result: {result}") -# 3. Use the client (no subprocess or network involved) -async def test_in_memory(): - async with client_inferred: # Or client_explicit - result = await client_inferred.call_tool("ping") - print(f"In-memory call result: {result[0].text}") # Output: pong - -# asyncio.run(test_in_memory()) +asyncio.run(main()) ``` -Communication happens through efficient in-memory queues, making it very fast. -## Choosing a Transport - -* **Local Development/Testing:** Use `PythonStdioTransport` (inferred from `.py` files) or `FastMCPTransport` (for same-process testing). -* **Connecting to Remote/Persistent Servers:** Use `StreamableHttpTransport` (recommended, default for HTTP URLs) or `SSETransport` (legacy option). -* **Running Packaged Tools:** Use `UvxStdioTransport` (Python/uv) or `NpxStdioTransport` (Node/npm) if you need to run MCP servers without local installation. -* **Integrating with Claude Desktop (or similar):** These tools typically expect to run a Python script, so your server should be runnable via `python your_server.py`, making `PythonStdioTransport` the relevant mechanism on the client side. \ No newline at end of file +Communication happens through efficient in-memory queues, making it very fast and ideal for unit testing. \ No newline at end of file diff --git a/docs/patterns/testing.mdx b/docs/patterns/testing.mdx index f8a846b4e..34c05a6f8 100644 --- a/docs/patterns/testing.mdx +++ b/docs/patterns/testing.mdx @@ -32,7 +32,7 @@ async def test_tool_functionality(mcp_server): # Pass the server directly to the Client constructor async with Client(mcp_server) as client: result = await client.call_tool("greet", {"name": "World"}) - assert "Hello, World!" in str(result[0]) + assert result[0].text == "Hello, World!" ``` This pattern creates a direct connection between the client and server, allowing you to test your server's functionality efficiently. diff --git a/docs/servers/proxy.mdx b/docs/servers/proxy.mdx index ee8d9a47a..9743b629a 100644 --- a/docs/servers/proxy.mdx +++ b/docs/servers/proxy.mdx @@ -89,7 +89,7 @@ proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy") You can also proxy an in-memory `FastMCP` instance, which is useful for adjusting the configuration or behavior of a server you don't completely control. ```python -from fastmcp import FastMCP +from fastmcp import FastMCP, Client # Original server original_server = FastMCP(name="Original") @@ -98,9 +98,12 @@ original_server = FastMCP(name="Original") def tool_a() -> str: return "A" -# Create a proxy of the original server +# To proxy an in-memory server, first create a Client to it. +client_to_original = Client(original_server) + +# Create a proxy of the original server using the client. proxy = FastMCP.from_client( - original_server, + client_to_original, name="Proxy Server" ) diff --git a/examples/in_memory_proxy_example.py b/examples/in_memory_proxy_example.py new file mode 100644 index 000000000..9620fa113 --- /dev/null +++ b/examples/in_memory_proxy_example.py @@ -0,0 +1,89 @@ +""" +This example demonstrates how to set up and use an in-memory FastMCP proxy. + +It illustrates the pattern: +1. Create an original FastMCP server with some tools. +2. Create a Client that connects to this original server (in-memory). +3. Create a proxy FastMCP server using FastMCP.from_client(), passing it the client from step 2. +4. Use another Client to connect to the proxy server (in-memory) and interact with the original server's tools through the proxy. +""" + +import asyncio + +from mcp.types import TextContent + +from fastmcp import FastMCP +from fastmcp.client import Client + + +class EchoService: + """A simple service to demonstrate with""" + + def echo(self, message: str) -> str: + return f"Original server echoes: {message}" + + +async def main(): + print("--- In-Memory FastMCP Proxy Example ---") + print("This example will walk through setting up an in-memory proxy.") + print("-----------------------------------------") + + # 1. Original Server Setup + print( + "\nStep 1: Setting up the Original Server (OriginalEchoServer) with an 'echo' tool..." + ) + original_server = FastMCP("OriginalEchoServer") + original_server.add_tool(EchoService().echo) + print(f" -> Original Server '{original_server.name}' created.") + + # 2. Client for Proxy + print("\nStep 2: Creating a Client to connect to the Original Server...") + print(" (This client will be used internally by the proxy server)") + client_to_original = Client(original_server) + print(f" -> Client for proxy created, targeting '{original_server.name}'.") + + # 3. Proxy Server Creation + print("\nStep 3: Creating the Proxy Server (InMemoryProxy)...") + print( + f" (Using FastMCP.from_client, passing it the client from Step 2 that targets '{original_server.name}')" + ) + proxy_server = FastMCP.from_client(client_to_original, name="InMemoryProxy") + print( + f" -> Proxy Server '{proxy_server.name}' created, proxying '{original_server.name}'." + ) + + # 4. Interacting via Proxy + print("\nStep 4: Using a new Client to connect to the Proxy Server and interact...") + async with Client(proxy_server) as final_client: + print(f" -> Successfully connected to proxy '{proxy_server.name}'.") + + print("\n Listing tools available via proxy...") + tools = await final_client.list_tools() + if tools: + print(" Available Tools:") + for tool in tools: + print( + f" - {tool.name} (Description: {tool.description or 'N/A'})" + ) + else: + print(" No tools found via proxy.") + + message_to_echo = "Hello, simplified proxied world!" + print(f"\n Calling 'echo' tool via proxy with message: '{message_to_echo}'") + try: + result = await final_client.call_tool("echo", {"message": message_to_echo}) + if result and isinstance(result[0], TextContent): + print(f" Result from proxied 'echo' call: '{result[0].text}'") + else: + print( + f" Error: Unexpected result format from proxied 'echo' call: {result}" + ) + except Exception as e: + print(f" Error calling 'echo' tool via proxy: {e}") + + print("\n-----------------------------------------") + print("--- In-Memory Proxy Example Finished ---") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index ddcab19d4..a4c59a58d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "python-dotenv>=1.1.0", "exceptiongroup>=1.2.2", "httpx>=0.28.1", - "mcp>=1.8.2,<2.0.0", + "mcp>=1.9.0,<2.0.0", "openapi-pydantic>=0.5.1", "rich>=13.9.4", "typer>=0.15.2", @@ -96,6 +96,7 @@ reportMissingTypeStubs = false useLibraryCodeForTypes = true venvPath = "." venv = ".venv" +strict = ["src/fastmcp/server/server.py"] [tool.ruff.lint] extend-select = ["I", "UP"] diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py index 68922a92c..feda85a0e 100644 --- a/src/fastmcp/cli/cli.py +++ b/src/fastmcp/cli/cli.py @@ -263,7 +263,7 @@ def dev( try: # Import server to get dependencies server = _import_server(file, server_object) - if hasattr(server, "dependencies"): + if hasattr(server, "dependencies") and server.dependencies is not None: with_packages = list(set(with_packages + server.dependencies)) env_vars = {} diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 000b99c00..320addb8c 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,9 +1,10 @@ import datetime -from contextlib import AbstractAsyncContextManager +from contextlib import AsyncExitStack from pathlib import Path from typing import Any, cast import mcp.types +from exceptiongroup import catch from mcp import ClientSession from pydantic import AnyUrl @@ -14,8 +15,9 @@ from fastmcp.client.roots import ( create_roots_callback, ) from fastmcp.client.sampling import SamplingHandler, create_sampling_callback -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.server import FastMCP +from fastmcp.utilities.exceptions import get_catch_handlers from .transports import ClientTransport, SessionKwargs, infer_transport @@ -33,8 +35,35 @@ class Client: """ MCP client that delegates connection management to a Transport instance. - The Client class is primarily concerned with MCP protocol logic, - while the Transport handles connection establishment and management. + The Client class is responsible for MCP protocol logic, while the Transport + handles connection establishment and management. Client provides methods + for working with resources, prompts, tools and other MCP capabilities. + + Args: + transport: Connection source specification, which can be: + - ClientTransport: Direct transport instance + - FastMCP: In-process FastMCP server + - AnyUrl | str: URL to connect to + - Path: File path for local socket + - dict: Transport configuration + roots: Optional RootsList or RootsHandler for filesystem access + sampling_handler: Optional handler for sampling requests + log_handler: Optional handler for log messages + message_handler: Optional handler for protocol messages + timeout: Optional timeout for requests (seconds or timedelta) + + Examples: + ```python + # Connect to FastMCP server + client = Client("http://localhost:8080") + + async with client: + # List available resources + resources = await client.list_resources() + + # Call a tool + result = await client.call_tool("my_tool", {"param": "value"}) + ``` """ def __init__( @@ -45,19 +74,22 @@ class Client: sampling_handler: SamplingHandler | None = None, log_handler: LogHandler | None = None, message_handler: MessageHandler | None = None, - read_timeout_seconds: datetime.timedelta | None = None, + timeout: datetime.timedelta | float | int | None = None, ): self.transport = infer_transport(transport) self._session: ClientSession | None = None - self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None + self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 + if isinstance(timeout, int | float): + timeout = datetime.timedelta(seconds=timeout) + self._session_kwargs: SessionKwargs = { "sampling_callback": None, "list_roots_callback": None, "logging_callback": log_handler, "message_handler": message_handler, - "read_timeout_seconds": read_timeout_seconds, + "read_timeout_seconds": timeout, } if roots is not None: @@ -91,9 +123,23 @@ class Client: async def __aenter__(self): if self._nesting_counter == 0: - # create new session - self._session_cm = self.transport.connect_session(**self._session_kwargs) - self._session = await self._session_cm.__aenter__() + # Create exit stack to manage both context managers + stack = AsyncExitStack() + await stack.__aenter__() + + # Add the exception handling context + stack.enter_context(catch(get_catch_handlers())) + + # the above catch will only apply once this __aenter__ finishes so + # we need to wrap the session creation in a new context in case it + # raises errors itself + with catch(get_catch_handlers()): + # Create and enter the transport session using the exit stack + session_cm = self.transport.connect_session(**self._session_kwargs) + self._session = await stack.enter_async_context(session_cm) + + # Store the stack for cleanup in __aexit__ + self._exit_stack = stack self._nesting_counter += 1 return self @@ -101,10 +147,14 @@ class Client: async def __aexit__(self, exc_type, exc_val, exc_tb): self._nesting_counter -= 1 - if self._nesting_counter == 0 and self._session_cm is not None: - await self._session_cm.__aexit__(exc_type, exc_val, exc_tb) - self._session_cm = None - self._session = None + if self._nesting_counter == 0: + # Exit the stack which will handle cleaning up the session + if self._exit_stack is not None: + try: + await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) + finally: + self._exit_stack = None + self._session = None # --- MCP Client Methods --- @@ -118,9 +168,12 @@ class Client: progress_token: str | int, progress: float, total: float | None = None, + message: str | None = None, ) -> None: """Send a progress notification.""" - await self.session.send_progress_notification(progress_token, progress, total) + await self.session.send_progress_notification( + progress_token, progress, total, message + ) async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None: """Send a logging/setLevel request.""" @@ -377,7 +430,10 @@ class Client: # --- Call Tool --- async def call_tool_mcp( - self, name: str, arguments: dict[str, Any] + self, + name: str, + arguments: dict[str, Any], + timeout: datetime.timedelta | float | int | None = None, ) -> mcp.types.CallToolResult: """Send a tools/call request and return the complete MCP protocol result. @@ -387,7 +443,7 @@ class Client: Args: name (str): The name of the tool to call. arguments (dict[str, Any]): Arguments to pass to the tool. - + timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. Returns: mcp.types.CallToolResult: The complete response object from the protocol, containing the tool result and any additional metadata. @@ -395,19 +451,25 @@ class Client: Raises: RuntimeError: If called while the client is not connected. """ - result = await self.session.call_tool(name=name, arguments=arguments) + + if isinstance(timeout, int | float): + timeout = datetime.timedelta(seconds=timeout) + result = await self.session.call_tool( + name=name, arguments=arguments, read_timeout_seconds=timeout + ) return result async def call_tool( self, name: str, arguments: dict[str, Any] | None = None, + timeout: datetime.timedelta | float | int | None = None, ) -> list[ mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource ]: """Call a tool on the server. - Unlike call_tool_mcp, this method raises a ClientError if the tool call results in an error. + Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. Args: name (str): The name of the tool to call. @@ -418,11 +480,15 @@ class Client: The content returned by the tool. Raises: - ClientError: If the tool call results in an error. + ToolError: If the tool call results in an error. RuntimeError: If called while the client is not connected. """ - result = await self.call_tool_mcp(name=name, arguments=arguments or {}) + result = await self.call_tool_mcp( + name=name, + arguments=arguments or {}, + timeout=timeout, + ) if result.isError: msg = cast(mcp.types.TextContent, result.content[0]).text - raise ClientError(msg) + raise ToolError(msg) return result.content diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 00d9e5b65..7faeab613 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -8,10 +8,9 @@ import sys import warnings from collections.abc import AsyncIterator from pathlib import Path -from typing import Any, TypedDict +from typing import Any, TypedDict, cast -from exceptiongroup import BaseExceptionGroup, catch -from mcp import ClientSession, McpError, StdioServerParameters +from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( ListRootsFnT, LoggingFnT, @@ -26,7 +25,6 @@ from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack -from fastmcp.exceptions import ClientError from fastmcp.server import FastMCP as FastMCPServer @@ -104,7 +102,12 @@ class WSTransport(ClientTransport): class SSETransport(ClientTransport): """Transport implementation that connects to an MCP server via Server-Sent Events.""" - def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None): + def __init__( + self, + url: str | AnyUrl, + headers: dict[str, str] | None = None, + sse_read_timeout: datetime.timedelta | float | int | None = None, + ): if isinstance(url, AnyUrl): url = str(url) if not isinstance(url, str) or not url.startswith("http"): @@ -112,11 +115,28 @@ class SSETransport(ClientTransport): self.url = url self.headers = headers or {} + if isinstance(sse_read_timeout, int | float): + sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) + self.sse_read_timeout = sse_read_timeout + @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - async with sse_client(self.url, headers=self.headers) as transport: + client_kwargs = {} + # sse_read_timeout has a default value set, so we can't pass None without overriding it + # instead we simply leave the kwarg out if it's not provided + if self.sse_read_timeout is not None: + client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds() + if session_kwargs.get("read_timeout_seconds", None) is not None: + read_timeout_seconds = cast( + datetime.timedelta, session_kwargs.get("read_timeout_seconds") + ) + client_kwargs["timeout"] = read_timeout_seconds.total_seconds() + + async with sse_client( + self.url, headers=self.headers, **client_kwargs + ) as transport: read_stream, write_stream = transport async with ClientSession( read_stream, write_stream, **session_kwargs @@ -131,7 +151,12 @@ class SSETransport(ClientTransport): class StreamableHttpTransport(ClientTransport): """Transport implementation that connects to an MCP server via Streamable HTTP Requests.""" - def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None): + def __init__( + self, + url: str | AnyUrl, + headers: dict[str, str] | None = None, + sse_read_timeout: datetime.timedelta | float | int | None = None, + ): if isinstance(url, AnyUrl): url = str(url) if not isinstance(url, str) or not url.startswith("http"): @@ -139,11 +164,25 @@ class StreamableHttpTransport(ClientTransport): self.url = url self.headers = headers or {} + if isinstance(sse_read_timeout, int | float): + sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout) + self.sse_read_timeout = sse_read_timeout + @contextlib.asynccontextmanager async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - async with streamablehttp_client(self.url, headers=self.headers) as transport: + client_kwargs = {} + # sse_read_timeout has a default value set, so we can't pass None without overriding it + # instead we simply leave the kwarg out if it's not provided + if self.sse_read_timeout is not None: + client_kwargs["sse_read_timeout"] = self.sse_read_timeout + if session_kwargs.get("read_timeout_seconds", None) is not None: + client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds") + + async with streamablehttp_client( + self.url, headers=self.headers, **client_kwargs + ) as transport: read_stream, write_stream, _ = transport async with ClientSession( read_stream, write_stream, **session_kwargs @@ -418,26 +457,12 @@ class FastMCPTransport(ClientTransport): async def connect_session( self, **session_kwargs: Unpack[SessionKwargs] ) -> AsyncIterator[ClientSession]: - def exception_handler(excgroup: BaseExceptionGroup): - for exc in excgroup.exceptions: - if isinstance(exc, BaseExceptionGroup): - exception_handler(exc) - raise exc - - def mcperror_handler(excgroup: BaseExceptionGroup): - for exc in excgroup.exceptions: - if isinstance(exc, BaseExceptionGroup): - mcperror_handler(exc) - raise ClientError(exc) - - # backport of 3.11's except* syntax - with catch({McpError: mcperror_handler, Exception: exception_handler}): - # create_connected_server_and_client_session manages the session lifecycle itself - async with create_connected_server_and_client_session( - server=self._fastmcp._mcp_server, - **session_kwargs, - ) as session: - yield session + # create_connected_server_and_client_session manages the session lifecycle itself + async with create_connected_server_and_client_session( + server=self._fastmcp._mcp_server, + **session_kwargs, + ) as session: + yield session def __repr__(self) -> str: return f"" @@ -519,12 +544,6 @@ def infer_transport( headers=server.get("headers", None), ) - # WebSocket transport - elif "ws_url" in server: - return WSTransport( - url=server["ws_url"], - ) - raise ValueError("Cannot determine transport type from dictionary") # the transport is an unknown type diff --git a/src/fastmcp/exceptions.py b/src/fastmcp/exceptions.py index c105cce8e..24864d8a0 100644 --- a/src/fastmcp/exceptions.py +++ b/src/fastmcp/exceptions.py @@ -1,5 +1,7 @@ """Custom exceptions for FastMCP.""" +from mcp import McpError # noqa: F401 + class FastMCPError(Exception): """Base error for FastMCP.""" diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py index 79a38d748..4ecd992a7 100644 --- a/src/fastmcp/server/context.py +++ b/src/fastmcp/server/context.py @@ -56,7 +56,7 @@ class Context: ctx.error("Error message") # Report progress - ctx.report_progress(50, 100) + ctx.report_progress(50, 100, "Processing") # Access resources data = ctx.read_resource("resource://data") @@ -96,7 +96,7 @@ class Context: return self.fastmcp._mcp_server.request_context async def report_progress( - self, progress: float, total: float | None = None + self, progress: float, total: float | None = None, message: str | None = None ) -> None: """Report progress for the current operation. @@ -115,7 +115,10 @@ class Context: return await self.request_context.session.send_progress_notification( - progress_token=progress_token, progress=progress, total=total + progress_token=progress_token, + progress=progress, + total=total, + message=message, ) async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]: diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index f4ed22b2b..8dbeacfee 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -10,9 +10,15 @@ from mcp.server.auth.middleware.bearer_auth import ( BearerAuthBackend, RequireAuthMiddleware, ) -from mcp.server.auth.provider import OAuthAuthorizationServerProvider +from mcp.server.auth.provider import ( + AccessTokenT, + AuthorizationCodeT, + OAuthAuthorizationServerProvider, + RefreshTokenT, +) from mcp.server.auth.routes import create_auth_routes from mcp.server.auth.settings import AuthSettings +from mcp.server.lowlevel.server import LifespanResultT from mcp.server.sse import SseServerTransport from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from starlette.applications import Starlette @@ -30,6 +36,7 @@ if TYPE_CHECKING: logger = get_logger(__name__) + _current_http_request: ContextVar[Request | None] = ContextVar( "http_request", default=None, @@ -62,7 +69,10 @@ class RequestContextMiddleware: def setup_auth_middleware_and_routes( - auth_server_provider: OAuthAuthorizationServerProvider | None, + auth_server_provider: OAuthAuthorizationServerProvider[ + AuthorizationCodeT, RefreshTokenT, AccessTokenT + ] + | None, auth_settings: AuthSettings | None, ) -> tuple[list[Middleware], list[BaseRoute], list[str]]: """Set up authentication middleware and routes if auth is enabled. @@ -136,10 +146,13 @@ def create_base_app( def create_sse_app( - server: FastMCP, + server: FastMCP[LifespanResultT], message_path: str, sse_path: str, - auth_server_provider: OAuthAuthorizationServerProvider | None = None, + auth_server_provider: OAuthAuthorizationServerProvider[ + AuthorizationCodeT, RefreshTokenT, AccessTokenT + ] + | None = None, auth_settings: AuthSettings | None = None, debug: bool = False, routes: list[BaseRoute] | None = None, @@ -236,10 +249,13 @@ def create_sse_app( def create_streamable_http_app( - server: FastMCP, + server: FastMCP[LifespanResultT], streamable_http_path: str, event_store: None = None, - auth_server_provider: OAuthAuthorizationServerProvider | None = None, + auth_server_provider: OAuthAuthorizationServerProvider[ + AuthorizationCodeT, RefreshTokenT, AccessTokenT + ] + | None = None, auth_settings: AuthSettings | None = None, json_response: bool = False, stateless_http: bool = False, diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 0ea8d734c..1d520bae5 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -66,7 +66,7 @@ DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] @asynccontextmanager -async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]: +async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]: """Default lifespan context manager that does nothing. Args: @@ -79,8 +79,10 @@ async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]: def _lifespan_wrapper( - app: FastMCP, - lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]], + app: FastMCP[LifespanResultT], + lifespan: Callable[ + [FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT] + ], ) -> Callable[ [MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT] ]: @@ -189,15 +191,13 @@ class FastMCP(Generic[LifespanResultT]): """ if transport is None: transport = "stdio" - if transport not in ["stdio", "streamable-http", "sse"]: + if transport not in {"stdio", "streamable-http", "sse"}: raise ValueError(f"Unknown transport: {transport}") if transport == "stdio": await self.run_stdio_async(**transport_kwargs) - elif transport == "streamable-http": - await self.run_http_async(transport="streamable-http", **transport_kwargs) - elif transport == "sse": - await self.run_http_async(transport="sse", **transport_kwargs) + elif transport in {"streamable-http", "sse"}: + await self.run_http_async(transport=transport, **transport_kwargs) else: raise ValueError(f"Unknown transport: {transport}") @@ -228,7 +228,7 @@ class FastMCP(Generic[LifespanResultT]): async def get_tools(self) -> dict[str, Tool]: """Get all registered tools, indexed by registered key.""" if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND: - tools = {} + tools: dict[str, Tool] = {} for server in self._mounted_servers.values(): server_tools = await server.get_tools() tools.update(server_tools) @@ -239,7 +239,7 @@ class FastMCP(Generic[LifespanResultT]): async def get_resources(self) -> dict[str, Resource]: """Get all registered resources, indexed by registered key.""" if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND: - resources = {} + resources: dict[str, Resource] = {} for server in self._mounted_servers.values(): server_resources = await server.get_resources() resources.update(server_resources) @@ -252,7 +252,7 @@ class FastMCP(Generic[LifespanResultT]): if ( templates := self._cache.get("resource_templates") ) is self._cache.NOT_FOUND: - templates = {} + templates: dict[str, ResourceTemplate] = {} for server in self._mounted_servers.values(): server_templates = await server.get_resource_templates() templates.update(server_templates) @@ -265,7 +265,7 @@ class FastMCP(Generic[LifespanResultT]): List all available prompts. """ if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND: - prompts = {} + prompts: dict[str, Prompt] = {} for server in self._mounted_servers.values(): server_prompts = await server.get_prompts() prompts.update(server_prompts) @@ -418,7 +418,7 @@ class FastMCP(Generic[LifespanResultT]): for server in self._mounted_servers.values(): if server.match_prompt(name): new_key = server.strip_prompt_prefix(name) - return await server.server._mcp_get_prompt(new_key, arguments) + return await server.server._mcp_get_prompt(new_key, arguments) else: raise NotFoundError(f"Unknown prompt: {name}") @@ -743,7 +743,8 @@ class FastMCP(Generic[LifespanResultT]): port: int | None = None, log_level: str | None = None, path: str | None = None, - uvicorn_config: dict | None = None, + uvicorn_config: dict[str, Any] | None = None, + middleware: list[Middleware] | None = None, ) -> None: """Run the server using HTTP transport. @@ -760,7 +761,7 @@ class FastMCP(Generic[LifespanResultT]): # lifespan is required for streamable http uvicorn_config["lifespan"] = "on" - app = self.http_app(path=path, transport=transport) + app = self.http_app(path=path, transport=transport, middleware=middleware) config = uvicorn.Config( app, @@ -779,7 +780,7 @@ class FastMCP(Generic[LifespanResultT]): log_level: str | None = None, path: str | None = None, message_path: str | None = None, - uvicorn_config: dict | None = None, + uvicorn_config: dict[str, Any] | None = None, ) -> None: """Run the server using SSE transport.""" @@ -901,7 +902,7 @@ class FastMCP(Generic[LifespanResultT]): port: int | None = None, log_level: str | None = None, path: str | None = None, - uvicorn_config: dict | None = None, + uvicorn_config: dict[str, Any] | None = None, ) -> None: # Deprecated since 2.3.2 warnings.warn( @@ -1128,7 +1129,7 @@ class MountedServer: def __init__( self, prefix: str, - server: FastMCP, + server: FastMCP[LifespanResultT], tool_separator: str | None = None, resource_separator: str | None = None, prompt_separator: str | None = None, diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index b8748b0d2..34209f5b0 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -1,6 +1,7 @@ from __future__ import annotations as _annotations -from typing import TYPE_CHECKING, Literal +import inspect +from typing import TYPE_CHECKING, Annotated, Literal from mcp.server.auth.settings import AuthSettings from pydantic import Field, model_validator @@ -28,16 +29,37 @@ class Settings(BaseSettings): test_mode: bool = False log_level: LOG_LEVEL = "INFO" - tool_attempt_parse_json_args: bool = Field( - default=False, - description=""" - Note: this enables a legacy behavior. If True, will attempt to parse - stringified JSON lists and objects strings in tool arguments before - passing them to the tool. This is an old behavior that can create - unexpected type coercion issues, but may be helpful for less powerful - LLMs that stringify JSON instead of passing actual lists and objects. - Defaults to False.""", - ) + client_raise_first_exceptiongroup_error: Annotated[ + bool, + Field( + default=True, + description=inspect.cleandoc( + """ + Many MCP components operate in anyio taskgroups, and raise + ExceptionGroups instead of exceptions. If this setting is True, FastMCP Clients + will `raise` the first error in any ExceptionGroup instead of raising + the ExceptionGroup as a whole. This is useful for debugging, but may + mask other errors. + """ + ), + ), + ] = True + tool_attempt_parse_json_args: Annotated[ + bool, + Field( + default=False, + description=inspect.cleandoc( + """ + Note: this enables a legacy behavior. If True, will attempt to parse + stringified JSON lists and objects strings in tool arguments before + passing them to the tool. This is an old behavior that can create + unexpected type coercion issues, but may be helpful for less powerful + LLMs that stringify JSON instead of passing actual lists and objects. + Defaults to False. + """ + ), + ), + ] = False @model_validator(mode="after") def setup_logging(self) -> Self: @@ -64,7 +86,10 @@ class ServerSettings(BaseSettings): nested_model_default_partial_update=True, ) - log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level) + log_level: Annotated[ + LOG_LEVEL, + Field(default_factory=lambda: Settings().log_level), + ] # HTTP settings host: str = "127.0.0.1" @@ -83,10 +108,13 @@ class ServerSettings(BaseSettings): # prompt settings on_duplicate_prompts: DuplicateBehavior = "warn" - dependencies: list[str] = Field( - default_factory=list, - description="List of dependencies to install in the server environment", - ) + dependencies: Annotated[ + list[str], + Field( + default_factory=list, + description="List of dependencies to install in the server environment", + ), + ] = [] # cache settings (for checking mounted servers) cache_expiration_seconds: float = 0 @@ -100,16 +128,4 @@ class ServerSettings(BaseSettings): ) -class ClientSettings(BaseSettings): - """FastMCP client settings.""" - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_CLIENT_", - env_file=".env", - extra="ignore", - ) - - log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level) - - settings = Settings() diff --git a/src/fastmcp/utilities/exceptions.py b/src/fastmcp/utilities/exceptions.py new file mode 100644 index 000000000..e50dc57b0 --- /dev/null +++ b/src/fastmcp/utilities/exceptions.py @@ -0,0 +1,49 @@ +from collections.abc import Callable, Iterable, Mapping +from typing import Any + +import httpx +import mcp.types +from exceptiongroup import BaseExceptionGroup +from mcp import McpError + +import fastmcp + + +def iter_exc(group: BaseExceptionGroup): + for exc in group.exceptions: + if isinstance(exc, BaseExceptionGroup): + yield from iter_exc(exc) + else: + yield exc + + +def _exception_handler(group: BaseExceptionGroup): + for leaf in iter_exc(group): + if isinstance(leaf, httpx.ConnectTimeout): + raise McpError( + error=mcp.types.ErrorData( + code=httpx.codes.REQUEST_TIMEOUT, + message="Timed out while waiting for response.", + ) + ) + raise leaf + + +# this catch handler is used to catch taskgroup exception groups and raise the +# first exception. This allows more sane debugging. +_catch_handlers: Mapping[ + type[BaseException] | Iterable[type[BaseException]], + Callable[[BaseExceptionGroup[Any]], Any], +] = { + Exception: _exception_handler, +} + + +def get_catch_handlers() -> Mapping[ + type[BaseException] | Iterable[type[BaseException]], + Callable[[BaseExceptionGroup[Any]], Any], +]: + if fastmcp.settings.settings.client_raise_first_exceptiongroup_error: + return _catch_handlers + else: + return {} diff --git a/tests/client/test_client.py b/tests/client/test_client.py index ff460b295..1d8c3187d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,6 +1,8 @@ +import asyncio from typing import cast import pytest +from mcp import McpError from pydantic import AnyUrl from fastmcp.client import Client @@ -27,6 +29,12 @@ def fastmcp_server(): """Add two numbers together.""" return a + b + @server.tool() + async def sleep(seconds: float) -> str: + """Sleep for a given number of seconds.""" + await asyncio.sleep(seconds) + return f"Slept for {seconds} seconds" + # Add a resource @server.resource(uri="data://users") async def get_users(): @@ -78,8 +86,8 @@ async def test_list_tools(fastmcp_server): result = await client.list_tools() # Check that our tools are available - assert len(result) == 2 - assert set(tool.name for tool in result) == {"greet", "add"} + assert len(result) == 3 + assert set(tool.name for tool in result) == {"greet", "add", "sleep"} async def test_list_tools_mcp(fastmcp_server): @@ -91,8 +99,8 @@ async def test_list_tools_mcp(fastmcp_server): # Check that we got the raw MCP ListToolsResult object assert hasattr(result, "tools") - assert len(result.tools) == 2 - assert set(tool.name for tool in result.tools) == {"greet", "add"} + assert len(result.tools) == 3 + assert set(tool.name for tool in result.tools) == {"greet", "add", "sleep"} async def test_call_tool(fastmcp_server): @@ -499,3 +507,39 @@ class TestErrorHandling: with pytest.raises(Exception) as excinfo: await client.read_resource(AnyUrl("error://resource/123")) assert "This is a resource error (xyz)" in str(excinfo.value) + + +class TestTimeout: + async def test_timeout(self, fastmcp_server: FastMCP): + async with Client( + transport=FastMCPTransport(fastmcp_server), timeout=0.01 + ) as client: + with pytest.raises( + McpError, + match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds", + ): + await client.call_tool("sleep", {"seconds": 0.1}) + + async def test_timeout_tool_call(self, fastmcp_server: FastMCP): + async with Client(transport=FastMCPTransport(fastmcp_server)) as client: + with pytest.raises(McpError): + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + + async def test_timeout_tool_call_overrides_client_timeout( + self, fastmcp_server: FastMCP + ): + async with Client( + transport=FastMCPTransport(fastmcp_server), + timeout=2, + ) as client: + with pytest.raises(McpError): + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + + async def test_timeout_tool_call_overrides_client_timeout_even_if_lower( + self, fastmcp_server: FastMCP + ): + async with Client( + transport=FastMCPTransport(fastmcp_server), + timeout=0.01, + ) as client: + await client.call_tool("sleep", {"seconds": 0.1}, timeout=2) diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 3259fb7d5..7ecfc7ee6 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -1,9 +1,11 @@ +import asyncio import json import sys from collections.abc import Generator import pytest import uvicorn +from mcp import McpError from mcp.types import TextResourceContents from starlette.applications import Starlette from starlette.routing import Mount @@ -31,6 +33,12 @@ def fastmcp_server(): """Add two numbers together.""" return a + b + @server.tool() + async def sleep(seconds: float) -> str: + """Sleep for a given number of seconds.""" + await asyncio.sleep(seconds) + return f"Slept for {seconds} seconds" + # Add a resource @server.resource(uri="data://users") async def get_users(): @@ -126,3 +134,53 @@ async def test_nested_sse_server_resolves_correctly(): ) as client: result = await client.ping() assert result is True + + +class TestTimeout: + @pytest.mark.skipif( + sys.platform == "win32", + reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", + ) + async def test_timeout(self, sse_server: str): + with pytest.raises( + McpError, + match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds", + ): + async with Client( + transport=SSETransport(sse_server), + timeout=0.01, + ) as client: + await client.call_tool("sleep", {"seconds": 0.1}) + + async def test_timeout_tool_call(self, sse_server: str): + async with Client(transport=SSETransport(sse_server)) as client: + with pytest.raises(McpError, match="Timed out"): + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + + async def test_timeout_tool_call_overrides_client_timeout_if_lower( + self, sse_server: str + ): + async with Client( + transport=SSETransport(sse_server), + timeout=2, + ) as client: + with pytest.raises(McpError, match="Timed out"): + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + + @pytest.mark.skipif( + sys.platform == "win32", + reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.", + ) + async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower( + self, sse_server: str + ): + """ + With SSE, the tool call timeout always takes precedence over the client. + + Note: on Windows, the behavior appears unpredictable. + """ + async with Client( + transport=SSETransport(sse_server), + timeout=0.01, + ) as client: + await client.call_tool("sleep", {"seconds": 0.1}, timeout=2) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index a388cc3cb..9b1528c5d 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -1,9 +1,11 @@ +import asyncio import json import sys from collections.abc import Generator import pytest import uvicorn +from mcp import McpError from mcp.types import TextResourceContents from starlette.applications import Starlette from starlette.routing import Mount @@ -31,6 +33,12 @@ def fastmcp_server(): """Add two numbers together.""" return a + b + @server.tool() + async def sleep(seconds: float) -> str: + """Sleep for a given number of seconds.""" + await asyncio.sleep(seconds) + return f"Slept for {seconds} seconds" + # Add a resource @server.resource(uri="data://users") async def get_users(): @@ -139,3 +147,42 @@ async def test_nested_streamable_http_server_resolves_correctly(): ) as client: result = await client.ping() assert result is True + + +class TestTimeout: + async def test_timeout(self, streamable_http_server: str): + # note this transport behaves differently than others and raises + # McpError from the *client* context + with pytest.raises(McpError, match="Timed out"): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + timeout=0.01, + ) as client: + await client.call_tool("sleep", {"seconds": 0.1}) + + async def test_timeout_tool_call(self, streamable_http_server: str): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + ) as client: + with pytest.raises(McpError): + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + + async def test_timeout_tool_call_overrides_client_timeout( + self, streamable_http_server: str + ): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + timeout=2, + ) as client: + with pytest.raises(McpError): + await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01) + + async def test_timeout_client_timeout_overrides_tool_call_timeout_if_lower( + self, streamable_http_server: str + ): + with pytest.raises(McpError): + async with Client( + transport=StreamableHttpTransport(streamable_http_server), + timeout=0.01, + ) as client: + await client.call_tool("sleep", {"seconds": 0.1}, timeout=2) diff --git a/tests/server/test_openapi.py b/tests/server/test_openapi.py index c763c8797..e92d33b48 100644 --- a/tests/server/test_openapi.py +++ b/tests/server/test_openapi.py @@ -15,7 +15,7 @@ from pydantic.networks import AnyUrl from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.server.openapi import ( FastMCPOpenAPI, OpenAPIResource, @@ -1029,7 +1029,7 @@ async def test_none_path_parameters_rejected( # Create a client and try to call a tool with a None path parameter async with Client(mcp_server) as client: # get_user has a required path parameter user_id - with pytest.raises(ClientError, match="Missing required path parameters"): + with pytest.raises(ToolError, match="Missing required path parameters"): await client.call_tool( "update_user_name_users__user_id__name_patch", { diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index aef2c0583..31a1c74d4 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -4,11 +4,12 @@ from typing import Any import mcp.types import pytest from dirty_equals import Contains +from mcp import McpError from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.server.proxy import FastMCPProxy USERS = [ @@ -109,7 +110,7 @@ class TestTools: assert proxy_result[0].text == "3" async def test_error_tool_raises_error(self, proxy_server): - with pytest.raises(ClientError, match=""): + with pytest.raises(ToolError, match=""): async with Client(proxy_server) as client: await client.call_tool("error_tool", {}) @@ -147,9 +148,7 @@ class TestResources: assert json.loads(result[0].text) == USERS async def test_read_resource_returns_none_if_not_found(self, proxy_server): - with pytest.raises( - ClientError, match="Unknown resource: resource://nonexistent" - ): + with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"): async with Client(proxy_server) as client: await client.read_resource("resource://nonexistent") diff --git a/tests/server/test_server.py b/tests/server/test_server.py index f60959674..4a7180b41 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -1,6 +1,7 @@ from typing import Annotated import pytest +from mcp import McpError from mcp.types import ( TextContent, TextResourceContents, @@ -8,7 +9,7 @@ from mcp.types import ( from pydantic import Field from fastmcp import Client, FastMCP -from fastmcp.exceptions import ClientError, NotFoundError +from fastmcp.exceptions import NotFoundError class TestCreateServer: @@ -296,7 +297,7 @@ class TestResourceDecorator: async def test_no_resources_before_decorator(self): mcp = FastMCP() - with pytest.raises(ClientError, match="Unknown resource"): + with pytest.raises(McpError, match="Unknown resource"): async with Client(mcp) as client: await client.read_resource("resource://data") diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index b96b2bef0..1f9ef6f2b 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -8,6 +8,7 @@ from typing import Annotated, Literal import pydantic_core import pytest +from mcp import McpError from mcp.types import ( BlobResourceContents, ImageContent, @@ -18,7 +19,7 @@ from pydantic import AnyUrl, Field from fastmcp import Client, Context, FastMCP from fastmcp.client.transports import FastMCPTransport -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage from fastmcp.resources import FileResource, FunctionResource from fastmcp.utilities.types import Image @@ -320,7 +321,7 @@ class TestToolParameters: async with Client(mcp) as client: with pytest.raises( - ClientError, + ToolError, match="Error calling tool 'my_tool'", ): await client.call_tool("my_tool", {"x": "not an int"}) @@ -365,7 +366,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": 0}) async def test_default_field_validation(self): @@ -376,7 +377,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": 0}) async def test_default_field_is_still_required_if_no_default_specified(self): @@ -387,7 +388,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {}) async def test_literal_type_validation_error(self): @@ -398,7 +399,7 @@ class TestToolParameters: pass async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": "c"}) async def test_literal_type_validation_success(self): @@ -426,7 +427,7 @@ class TestToolParameters: return x.value async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": "some-color"}) async def test_enum_type_validation_success(self): @@ -462,7 +463,7 @@ class TestToolParameters: assert isinstance(result[0], TextContent) assert result[0].text == "1.0" - with pytest.raises(ClientError, match="Error calling tool 'analyze'"): + with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": "not a number"}) async def test_path_type(self): @@ -489,7 +490,7 @@ class TestToolParameters: return str(path) async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'send_path'"): + with pytest.raises(ToolError, match="Error calling tool 'send_path'"): await client.call_tool("send_path", {"path": 1}) async def test_uuid_type(self): @@ -515,7 +516,7 @@ class TestToolParameters: return str(x) async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'send_uuid'"): + with pytest.raises(ToolError, match="Error calling tool 'send_uuid'"): await client.call_tool("send_uuid", {"x": "not a uuid"}) async def test_datetime_type(self): @@ -554,7 +555,7 @@ class TestToolParameters: return x.isoformat() async with Client(mcp) as client: - with pytest.raises(ClientError, match="Error calling tool 'send_datetime'"): + with pytest.raises(ToolError, match="Error calling tool 'send_datetime'"): await client.call_tool("send_datetime", {"x": "not a datetime"}) async def test_date_type(self): @@ -1230,7 +1231,7 @@ class TestPrompts: async def test_get_unknown_prompt(self): """Test error when getting unknown prompt.""" mcp = FastMCP() - with pytest.raises(ClientError, match="Unknown prompt"): + with pytest.raises(McpError, match="Unknown prompt"): async with Client(mcp) as client: await client.get_prompt("unknown") @@ -1242,7 +1243,7 @@ class TestPrompts: def prompt_fn(name: str) -> str: return f"Hello, {name}!" - with pytest.raises(ClientError, match="Missing required arguments"): + with pytest.raises(McpError, match="Missing required arguments"): async with Client(mcp) as client: await client.get_prompt("prompt_fn") diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 5fe447055..89f1c4822 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -4,7 +4,7 @@ from pydantic import BaseModel from fastmcp import FastMCP, Image from fastmcp.client import Client -from fastmcp.exceptions import ClientError +from fastmcp.exceptions import ToolError from fastmcp.tools.tool import Tool from fastmcp.utilities.tests import temporary_settings @@ -299,7 +299,7 @@ class TestLegacyToolJsonParsing: async with Client(mcp) as client: with pytest.raises( - ClientError, + ToolError, match="Error calling tool 'process_list'", ): await client.call_tool("process_list", {"items": "['a', 'b', 3]"}) diff --git a/uv.lock b/uv.lock index 18220c0af..8613b7367 100644 --- a/uv.lock +++ b/uv.lock @@ -340,7 +340,7 @@ dev = [ requires-dist = [ { name = "exceptiongroup", specifier = ">=1.2.2" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "mcp", specifier = ">=1.8.0,<2.0.0" }, + { name = "mcp", specifier = ">=1.9.0,<2.0.0" }, { name = "openapi-pydantic", specifier = ">=0.5.1" }, { name = "python-dotenv", specifier = ">=1.1.0" }, { name = "rich", specifier = ">=13.9.4" }, @@ -573,7 +573,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.8.0" +version = "1.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -586,9 +586,9 @@ dependencies = [ { name = "starlette" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/97/0a3e08559557b0ac5799f9fb535fbe5a4e4dcdd66ce9d32e7a74b4d0534d/mcp-1.8.0.tar.gz", hash = "sha256:263dfb700540b726c093f0c3e043f66aded0730d0b51f04eb0a3eb90055fe49b", size = 264641, upload-time = "2025-05-08T20:09:06.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 } wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/b2/4ac3bd17b1fdd65658f18de4eb0c703517ee0b483dc5f56467802a9197e0/mcp-1.8.0-py3-none-any.whl", hash = "sha256:889d9d3b4f12b7da59e7a3933a0acadae1fce498bfcd220defb590aa291a1334", size = 119544, upload-time = "2025-05-08T20:09:04.458Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 }, ] [[package]]