Jeremiah Lowin commited on
Commit
870eb22
·
2 Parent(s): 98f443b622d44c

Merge branch 'main' into sse-bugfix

Browse files
.github/workflows/run-static.yml CHANGED
@@ -14,13 +14,10 @@ on:
14
  - "uv.lock"
15
  - "pyproject.toml"
16
  - ".github/workflows/**"
 
 
17
  pull_request:
18
- paths:
19
- - "src/**"
20
- - "tests/**"
21
- - "uv.lock"
22
- - "pyproject.toml"
23
- - ".github/workflows/**"
24
  workflow_dispatch:
25
 
26
  permissions:
 
14
  - "uv.lock"
15
  - "pyproject.toml"
16
  - ".github/workflows/**"
17
+
18
+ # run on all pull requests because these checks are required and will block merges otherwise
19
  pull_request:
20
+
 
 
 
 
 
21
  workflow_dispatch:
22
 
23
  permissions:
.github/workflows/run-tests.yml CHANGED
@@ -13,13 +13,9 @@ on:
13
  - "uv.lock"
14
  - "pyproject.toml"
15
  - ".github/workflows/**"
 
 
16
  pull_request:
17
- paths:
18
- - "src/**"
19
- - "tests/**"
20
- - "uv.lock"
21
- - "pyproject.toml"
22
- - ".github/workflows/**"
23
 
24
  workflow_dispatch:
25
 
@@ -45,18 +41,10 @@ jobs:
45
  with:
46
  enable-cache: true
47
  cache-dependency-glob: "uv.lock"
48
-
49
- - name: Set up Python ${{ matrix.python-version }}
50
- run: uv python install ${{ matrix.python-version }}
51
 
52
  - name: Install FastMCP
53
- run: uv sync --dev
54
-
55
- - name: Fix pyreadline on Windows
56
- if: matrix.os == 'windows-latest'
57
- run: |
58
- uv pip uninstall -y pyreadline
59
- uv pip install pyreadline3
60
 
61
  - name: Run tests
62
- run: uv run --frozen pytest
 
13
  - "uv.lock"
14
  - "pyproject.toml"
15
  - ".github/workflows/**"
16
+
17
+ # run on all pull requests because these checks are required and will block merges otherwise
18
  pull_request:
 
 
 
 
 
 
19
 
20
  workflow_dispatch:
21
 
 
41
  with:
42
  enable-cache: true
43
  cache-dependency-glob: "uv.lock"
44
+ python-version: ${{ matrix.python-version }}
 
 
45
 
46
  - name: Install FastMCP
47
+ run: uv sync --dev --locked
 
 
 
 
 
 
48
 
49
  - name: Run tests
50
+ run: uv run pytest
docs/clients/client.mdx CHANGED
@@ -30,9 +30,8 @@ The following inference rules are used to determine the appropriate `ClientTrans
30
  3. **`Path` or `str` pointing to an existing file**:
31
  * If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`.
32
  * If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`.
33
- 4. **`AnyUrl` or `str` pointing to a URL**:
34
- * If it starts with `http://` or `https://`: Creates an `SSETransport`.
35
- * If it starts with `ws://` or `wss://`: Creates a `WSTransport`.
36
  5. **Other**: Raises a `ValueError` if the type cannot be inferred.
37
 
38
  ```python
@@ -41,24 +40,24 @@ from fastmcp import Client, FastMCP
41
 
42
  # Example transports (more details in Transports page)
43
  server_instance = FastMCP(name="TestServer") # In-memory server
44
- sse_url = "http://localhost:8000/sse" # SSE server URL
45
  ws_url = "ws://localhost:9000" # WebSocket server URL
46
  server_script = "my_mcp_server.py" # Path to a Python server file
47
 
48
  # Client automatically infers the transport type
49
  client_in_memory = Client(server_instance)
50
- client_sse = Client(sse_url)
51
  client_ws = Client(ws_url)
52
  client_stdio = Client(server_script)
53
 
54
  print(client_in_memory.transport)
55
- print(client_sse.transport)
56
  print(client_ws.transport)
57
  print(client_stdio.transport)
58
 
59
  # Expected Output (types may vary slightly based on environment):
60
  # <FastMCP(server='TestServer')>
61
- # <SSE(url='http://localhost:8000/sse')>
62
  # <WebSocket(url='ws://localhost:9000')>
63
  # <PythonStdioTransport(command='python', args=['/path/to/your/my_mcp_server.py'])>
64
  ```
@@ -115,14 +114,18 @@ The standard client methods return user-friendly representations that may change
115
  tools = await client.list_tools()
116
  # tools -> list[mcp.types.Tool]
117
  ```
118
- * **`call_tool(name: str, arguments: dict[str, Any] | None = None)`**: Executes a tool on the server.
119
  ```python
120
  result = await client.call_tool("add", {"a": 5, "b": 3})
121
  # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
122
  print(result[0].text) # Assuming TextContent, e.g., '8'
 
 
 
123
  ```
124
  * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
125
  * Returns a list of content objects (usually `TextContent` or `ImageContent`).
 
126
 
127
  #### Resource Operations
128
 
@@ -191,6 +194,45 @@ These methods are especially useful for debugging or when you need to access met
191
 
192
  MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  #### LLM Sampling
196
 
 
30
  3. **`Path` or `str` pointing to an existing file**:
31
  * If it ends with `.py`: Creates a `PythonStdioTransport` to run the script using `python`.
32
  * If it ends with `.js`: Creates a `NodeStdioTransport` to run the script using `node`.
33
+ 4. **`AnyUrl` or `str` pointing to a URL that begins with `http://` or `https://`**:
34
+ * Creates a `StreamableHttpTransport`
 
35
  5. **Other**: Raises a `ValueError` if the type cannot be inferred.
36
 
37
  ```python
 
40
 
41
  # Example transports (more details in Transports page)
42
  server_instance = FastMCP(name="TestServer") # In-memory server
43
+ http_url = "https://example.com/mcp" # HTTP server URL
44
  ws_url = "ws://localhost:9000" # WebSocket server URL
45
  server_script = "my_mcp_server.py" # Path to a Python server file
46
 
47
  # Client automatically infers the transport type
48
  client_in_memory = Client(server_instance)
49
+ client_http = Client(http_url)
50
  client_ws = Client(ws_url)
51
  client_stdio = Client(server_script)
52
 
53
  print(client_in_memory.transport)
54
+ print(client_http.transport)
55
  print(client_ws.transport)
56
  print(client_stdio.transport)
57
 
58
  # Expected Output (types may vary slightly based on environment):
59
  # <FastMCP(server='TestServer')>
60
+ # <StreamableHttp(url='https://example.com/mcp')>
61
  # <WebSocket(url='ws://localhost:9000')>
62
  # <PythonStdioTransport(command='python', args=['/path/to/your/my_mcp_server.py'])>
63
  ```
 
114
  tools = await client.list_tools()
115
  # tools -> list[mcp.types.Tool]
116
  ```
117
+ * **`call_tool(name: str, arguments: dict[str, Any] | None = None, timeout: float | None = None)`**: Executes a tool on the server.
118
  ```python
119
  result = await client.call_tool("add", {"a": 5, "b": 3})
120
  # result -> list[mcp.types.TextContent | mcp.types.ImageContent | ...]
121
  print(result[0].text) # Assuming TextContent, e.g., '8'
122
+
123
+ # With timeout (aborts if execution takes longer than 2 seconds)
124
+ result = await client.call_tool("long_running_task", {"param": "value"}, timeout=2.0)
125
  ```
126
  * Arguments are passed as a dictionary. FastMCP servers automatically handle JSON string parsing for complex types if needed.
127
  * Returns a list of content objects (usually `TextContent` or `ImageContent`).
128
+ * The optional `timeout` parameter limits the maximum execution time (in seconds) for this specific call, overriding any client-level timeout.
129
 
130
  #### Resource Operations
131
 
 
194
 
195
  MCP allows servers to interact with clients in order to provide additional capabilities. The `Client` constructor accepts additional configuration to handle these server requests.
196
 
197
+ #### Timeout Control
198
+
199
+ <VersionBadge version="2.3.4" />
200
+
201
+ You can control request timeouts at both the client level and individual request level:
202
+
203
+ ```python
204
+ from fastmcp import Client
205
+ from fastmcp.exceptions import McpError
206
+
207
+ # Client with a global 5-second timeout for all requests
208
+ client = Client(
209
+ my_mcp_server,
210
+ timeout=5.0 # Default timeout in seconds
211
+ )
212
+
213
+ async with client:
214
+ # This uses the global 5-second timeout
215
+ result1 = await client.call_tool("quick_task", {"param": "value"})
216
+
217
+ # This specifies a 10-second timeout for this specific call
218
+ result2 = await client.call_tool("slow_task", {"param": "value"}, timeout=10.0)
219
+
220
+ try:
221
+ # This will likely timeout
222
+ result3 = await client.call_tool("medium_task", {"param": "value"}, timeout=0.01)
223
+ except McpError as e:
224
+ # Handle timeout error
225
+ print(f"The task timed out: {e}")
226
+ ```
227
+
228
+ <Warning>
229
+ Timeout behavior varies between transport types:
230
+
231
+ - With **SSE** transport, the per-request (tool call) timeout **always** takes precedence, regardless of which is lower.
232
+ - With **HTTP** transport, the **lower** of the two timeouts (client or tool call) takes precedence.
233
+
234
+ 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.
235
+ </Warning>
236
 
237
  #### LLM Sampling
238
 
docs/clients/transports.mdx CHANGED
@@ -13,6 +13,19 @@ The FastMCP `Client` relies on a `ClientTransport` object to handle the specific
13
 
14
  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.
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  ## Network Transports
18
 
@@ -22,70 +35,122 @@ These transports connect to servers running over a network, typically long-runni
22
 
23
  <VersionBadge version="2.3.0" />
24
 
25
- * **Class:** `fastmcp.client.transports.StreamableHttpTransport`
26
- * **Inferred From:** `http://` or `https://` URLs (default for HTTP URLs as of v2.3.0)
27
- * **Use Case:** Connecting to persistent MCP servers exposed over HTTP/S using FastMCP's `mcp.run(transport="streamable-http")` mode.
28
-
29
  Streamable HTTP is the recommended transport for web-based deployments, providing efficient bidirectional communication over HTTP.
30
 
31
- ```python
32
- from fastmcp import Client
33
- from fastmcp.client.transports import StreamableHttpTransport
 
 
 
 
34
 
35
- http_url = "http://localhost:8000/mcp"
36
 
37
- # Option 1: Inferred transport (default for HTTP URLs)
38
- client_inferred = Client(http_url)
 
39
 
40
- # Option 2: Explicit transport (e.g., to add custom headers)
41
- headers = {"Authorization": "Bearer mytoken"}
42
- transport_explicit = StreamableHttpTransport(url=http_url, headers=headers)
43
- client_explicit = Client(transport_explicit)
44
 
45
- async def use_streamable_http_client(client):
46
  async with client:
47
  tools = await client.list_tools()
48
- print(f"Connected via Streamable HTTP, found tools: {tools}")
49
 
50
- # asyncio.run(use_streamable_http_client(client_inferred))
51
- # asyncio.run(use_streamable_http_client(client_explicit))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  ```
53
 
54
  ### SSE (Server-Sent Events)
55
 
56
- * **Class:** `fastmcp.client.transports.SSETransport`
57
- * **Inferred From:** Not automatically inferred for most HTTP URLs (as of v2.3.0)
58
- * **Use Case:** Connecting to MCP servers using Server-Sent Events, often using FastMCP's `mcp.run(transport="sse")` mode.
 
 
59
 
60
- While SSE is still supported, Streamable HTTP is the recommended transport for new web-based deployments.
 
 
 
 
 
 
61
 
62
  ```python
63
  from fastmcp import Client
64
  from fastmcp.client.transports import SSETransport
 
65
 
66
- sse_url = "http://localhost:8000/sse"
 
67
 
68
- # Since v2.3.0, HTTP URLs default to StreamableHttpTransport,
69
- # so you must explicitly use SSETransport for SSE connections
70
- transport_explicit = SSETransport(url=sse_url)
71
- client_explicit = Client(transport_explicit)
72
 
73
- async def use_sse_client(client):
74
  async with client:
75
  tools = await client.list_tools()
76
- print(f"Connected via SSE, found tools: {tools}")
77
 
78
- # asyncio.run(use_sse_client(client_explicit))
79
  ```
80
- ## Stdio Transports
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
 
82
  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.
83
 
84
  ### Python Stdio
85
 
86
- * **Class:** `fastmcp.client.transports.PythonStdioTransport`
87
- * **Inferred From:** Paths to `.py` files.
88
- * **Use Case:** Running a Python-based MCP server script (like one using FastMCP or the base `mcp` library) in a subprocess.
89
 
90
  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.
91
 
@@ -93,39 +158,37 @@ This is the most common way to interact with local FastMCP servers during develo
93
  from fastmcp import Client
94
  from fastmcp.client.transports import PythonStdioTransport
95
 
96
- server_script = "my_mcp_server.py" # Assumes this file exists and runs mcp.run()
97
 
98
  # Option 1: Inferred transport
99
- client_inferred = Client(server_script)
100
 
101
- # Option 2: Explicit transport (e.g., to use a specific python executable or add args)
102
- transport_explicit = PythonStdioTransport(
103
  script_path=server_script,
104
- python_cmd="/usr/bin/python3.11", # Specify python version
105
- # args=["--some-server-arg"], # Pass args to the script
106
- # env={"MY_VAR": "value"}, # Set environment variables
107
- # cwd="/path/to/run/in" # Set working directory
108
  )
109
- client_explicit = Client(transport_explicit)
110
 
111
- async def use_stdio_client(client):
112
  async with client:
113
  tools = await client.list_tools()
114
  print(f"Connected via Python Stdio, found tools: {tools}")
115
 
116
- # asyncio.run(use_stdio_client(client_inferred))
117
- # asyncio.run(use_stdio_client(client_explicit))
118
  ```
119
 
120
  <Warning>
121
- 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.
122
  </Warning>
123
 
124
  ### Node.js Stdio
125
 
126
- * **Class:** `fastmcp.client.transports.NodeStdioTransport`
127
- * **Inferred From:** Paths to `.js` files.
128
- * **Use Case:** Running a Node.js-based MCP server script in a subprocess.
129
 
130
  Similar to the Python transport, but for JavaScript servers.
131
 
@@ -133,112 +196,111 @@ Similar to the Python transport, but for JavaScript servers.
133
  from fastmcp import Client
134
  from fastmcp.client.transports import NodeStdioTransport
135
 
136
- node_server_script = "my_mcp_server.js" # Assumes this JS file starts an MCP server on stdio
137
 
138
  # Option 1: Inferred transport
139
- client_inferred = Client(node_server_script)
140
 
141
  # Option 2: Explicit transport
142
- transport_explicit = NodeStdioTransport(
143
  script_path=node_server_script,
144
- node_cmd="node" # Or specify path to Node executable
145
  )
146
- client_explicit = Client(transport_explicit)
 
 
 
 
 
147
 
148
- # Usage is the same as other clients
149
- # async with client_explicit:
150
- # tools = await client_explicit.list_tools()
151
  ```
152
 
153
  ### UVX Stdio (Experimental)
154
 
155
- * **Class:** `fastmcp.client.transports.UvxStdioTransport`
156
- * **Inferred From:** Not automatically inferred. Must be instantiated explicitly.
157
- * **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.
158
 
159
- This is useful for executing MCP servers distributed as command-line tools or packages.
160
 
161
  ```python
 
162
  from fastmcp.client.transports import UvxStdioTransport
163
 
164
- # Example: Run a hypothetical 'cloud-analyzer-mcp' tool via uvx
165
- # Assume this tool, when run, starts an MCP server on stdio
166
  transport = UvxStdioTransport(
167
  tool_name="cloud-analyzer-mcp",
168
- # from_package="cloud-analyzer-cli", # Optionally specify package if tool name differs
169
- # with_packages=["boto3", "requests"], # Add dependencies if needed
170
- # tool_args=["--config", "prod.yaml"] # Pass args to the tool itself
171
  )
172
  client = Client(transport)
173
 
174
- # async with client:
175
- # analysis = await client.call_tool("analyze_bucket", {"name": "my-data"})
 
 
 
 
176
  ```
177
 
178
  ### NPX Stdio (Experimental)
179
 
180
- * **Class:** `fastmcp.client.transports.NpxStdioTransport`
181
- * **Inferred From:** Not automatically inferred. Must be instantiated explicitly.
182
- * **Use Case:** Running an MCP server packaged as an NPM package using `npx`.
183
 
184
  Similar to `UvxStdioTransport`, but for the Node.js ecosystem.
185
 
186
  ```python
 
187
  from fastmcp.client.transports import NpxStdioTransport
188
 
189
- # Example: Run a hypothetical 'npm-mcp-server-package' via npx
190
  transport = NpxStdioTransport(
191
- package="npm-mcp-server-package",
192
- # args=["--port", "stdio"] # Args passed to the package script
193
  )
194
  client = Client(transport)
195
 
196
- # async with client:
197
- # response = await client.call_tool("get_npm_data", {})
 
 
 
 
198
  ```
199
 
200
  ## In-Memory Transports
201
 
202
  ### FastMCP Transport
203
 
204
- * **Class:** `fastmcp.client.transports.FastMCPTransport`
205
- * **Inferred From:** An instance of `fastmcp.server.FastMCP`.
206
- * **Use Case:** Connecting directly to a `FastMCP` server instance running in the *same Python process*.
207
 
208
- This is extremely useful for:
209
- * **Testing:** Writing unit or integration tests for your FastMCP server without needing subprocesses or network connections.
210
- * **Embedding:** Using an MCP server as a component within a larger application.
211
 
212
  ```python
213
  from fastmcp import FastMCP, Client
214
- from fastmcp.client.transports import FastMCPTransport
215
 
216
  # 1. Create your FastMCP server instance
217
  server = FastMCP(name="InMemoryServer")
 
218
  @server.tool()
219
- def ping(): return "pong"
 
220
 
221
  # 2. Create a client pointing directly to the server instance
222
- # Option A: Inferred
223
- client_inferred = Client(server)
224
-
225
- # Option B: Explicit
226
- transport_explicit = FastMCPTransport(mcp=server)
227
- client_explicit = Client(transport_explicit)
228
 
229
- # 3. Use the client (no subprocess or network involved)
230
- async def test_in_memory():
231
- async with client_inferred: # Or client_explicit
232
- result = await client_inferred.call_tool("ping")
233
- print(f"In-memory call result: {result[0].text}") # Output: pong
234
 
235
- # asyncio.run(test_in_memory())
236
  ```
237
- Communication happens through efficient in-memory queues, making it very fast.
238
-
239
- ## Choosing a Transport
240
 
241
- * **Local Development/Testing:** Use `PythonStdioTransport` (inferred from `.py` files) or `FastMCPTransport` (for same-process testing).
242
- * **Connecting to Remote/Persistent Servers:** Use `StreamableHttpTransport` (recommended, default for HTTP URLs) or `SSETransport` (legacy option).
243
- * **Running Packaged Tools:** Use `UvxStdioTransport` (Python/uv) or `NpxStdioTransport` (Node/npm) if you need to run MCP servers without local installation.
244
- * **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.
 
13
 
14
  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.
15
 
16
+ <Tip>
17
+ 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.
18
+ </Tip>
19
+
20
+ ## Choosing a Transport
21
+
22
+ Choose the transport that best fits your use case:
23
+
24
+ - **Connecting to Remote/Persistent Servers:** Use `StreamableHttpTransport` (recommended, default for HTTP URLs) or `SSETransport` (legacy option) for web-based deployments.
25
+
26
+ - **Local Development/Testing:** Use `FastMCPTransport` for in-memory, same-process testing of your FastMCP servers.
27
+
28
+ - **Running Local Servers:** Use `UvxStdioTransport` (Python/uv) or `NpxStdioTransport` (Node/npm) if you need to run MCP servers as packaged tools.
29
 
30
  ## Network Transports
31
 
 
35
 
36
  <VersionBadge version="2.3.0" />
37
 
 
 
 
 
38
  Streamable HTTP is the recommended transport for web-based deployments, providing efficient bidirectional communication over HTTP.
39
 
40
+ #### Overview
41
+
42
+ - **Class:** `fastmcp.client.transports.StreamableHttpTransport`
43
+ - **Inferred From:** URLs starting with `http://` or `https://` (default for HTTP URLs since v2.3.0)
44
+ - **Server Compatibility:** Works with FastMCP servers running in `streamable-http` mode
45
+
46
+ #### Basic Usage
47
 
48
+ The simplest way to use Streamable HTTP is to let the transport be inferred from a URL:
49
 
50
+ ```python
51
+ from fastmcp import Client
52
+ import asyncio
53
 
54
+ # The Client automatically uses StreamableHttpTransport for HTTP URLs
55
+ client = Client("https://example.com/mcp")
 
 
56
 
57
+ async def main():
58
  async with client:
59
  tools = await client.list_tools()
60
+ print(f"Available tools: {tools}")
61
 
62
+ asyncio.run(main())
63
+ ```
64
+
65
+ #### Authentication with Headers
66
+
67
+ For servers requiring authentication:
68
+
69
+ ```python
70
+ from fastmcp import Client
71
+ from fastmcp.client.transports import StreamableHttpTransport
72
+
73
+ # Create transport with authentication headers
74
+ transport = StreamableHttpTransport(
75
+ url="https://example.com/mcp",
76
+ headers={"Authorization": "Bearer your-token-here"}
77
+ )
78
+
79
+ client = Client(transport)
80
  ```
81
 
82
  ### SSE (Server-Sent Events)
83
 
84
+ <VersionBadge version="2.0.0" />
85
+
86
+ 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.
87
+
88
+ #### Overview
89
 
90
+ - **Class:** `fastmcp.client.transports.SSETransport`
91
+ - **Inferred From:** Not automatically inferred for HTTP URLs since v2.3.0 (must be explicitly specified)
92
+ - **Server Compatibility:** Works with FastMCP servers running in `sse` mode
93
+
94
+ #### Basic Usage
95
+
96
+ Since v2.3.0, you must explicitly create an `SSETransport` for SSE connections:
97
 
98
  ```python
99
  from fastmcp import Client
100
  from fastmcp.client.transports import SSETransport
101
+ import asyncio
102
 
103
+ # Create an SSE transport
104
+ transport = SSETransport(url="https://example.com/sse")
105
 
106
+ # Pass the transport to the client
107
+ client = Client(transport)
 
 
108
 
109
+ async def main():
110
  async with client:
111
  tools = await client.list_tools()
112
+ print(f"Available tools: {tools}")
113
 
114
+ asyncio.run(main())
115
  ```
116
+
117
+ #### Authentication with Headers
118
+
119
+ SSE transport also supports custom headers for authentication:
120
+
121
+ ```python
122
+ from fastmcp import Client
123
+ from fastmcp.client.transports import SSETransport
124
+
125
+ # Create SSE transport with authentication headers
126
+ transport = SSETransport(
127
+ url="https://example.com/sse",
128
+ headers={"Authorization": "Bearer your-token-here"}
129
+ )
130
+
131
+ client = Client(transport)
132
+ ```
133
+
134
+ #### When to Use SSE vs. Streamable HTTP
135
+
136
+ - **Use Streamable HTTP when:**
137
+ - Setting up new deployments (recommended default)
138
+ - You need bidirectional streaming
139
+ - You're connecting to FastMCP servers running in `streamable-http` mode
140
+
141
+ - **Use SSE when:**
142
+ - Connecting to legacy FastMCP servers running in `sse` mode
143
+ - Working with infrastructure optimized for Server-Sent Events
144
+
145
+ ## Local Transports
146
 
147
  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.
148
 
149
  ### Python Stdio
150
 
151
+ - **Class:** `fastmcp.client.transports.PythonStdioTransport`
152
+ - **Inferred From:** Paths to `.py` files
153
+ - **Use Case:** Running a Python-based MCP server script in a subprocess
154
 
155
  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.
156
 
 
158
  from fastmcp import Client
159
  from fastmcp.client.transports import PythonStdioTransport
160
 
161
+ server_script = "my_mcp_server.py" # Path to your server script
162
 
163
  # Option 1: Inferred transport
164
+ client = Client(server_script)
165
 
166
+ # Option 2: Explicit transport with custom configuration
167
+ transport = PythonStdioTransport(
168
  script_path=server_script,
169
+ python_cmd="/usr/bin/python3.11", # Optional: specify Python interpreter
170
+ # args=["--some-server-arg"], # Optional: pass arguments to the script
171
+ # env={"MY_VAR": "value"}, # Optional: set environment variables
 
172
  )
173
+ client = Client(transport)
174
 
175
+ async def main():
176
  async with client:
177
  tools = await client.list_tools()
178
  print(f"Connected via Python Stdio, found tools: {tools}")
179
 
180
+ asyncio.run(main())
 
181
  ```
182
 
183
  <Warning>
184
+ 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.
185
  </Warning>
186
 
187
  ### Node.js Stdio
188
 
189
+ - **Class:** `fastmcp.client.transports.NodeStdioTransport`
190
+ - **Inferred From:** Paths to `.js` files
191
+ - **Use Case:** Running a Node.js-based MCP server script in a subprocess
192
 
193
  Similar to the Python transport, but for JavaScript servers.
194
 
 
196
  from fastmcp import Client
197
  from fastmcp.client.transports import NodeStdioTransport
198
 
199
+ node_server_script = "my_mcp_server.js" # Path to your Node.js server script
200
 
201
  # Option 1: Inferred transport
202
+ client = Client(node_server_script)
203
 
204
  # Option 2: Explicit transport
205
+ transport = NodeStdioTransport(
206
  script_path=node_server_script,
207
+ node_cmd="node" # Optional: specify path to Node executable
208
  )
209
+ client = Client(transport)
210
+
211
+ async def main():
212
+ async with client:
213
+ tools = await client.list_tools()
214
+ print(f"Connected via Node.js Stdio, found tools: {tools}")
215
 
216
+ asyncio.run(main())
 
 
217
  ```
218
 
219
  ### UVX Stdio (Experimental)
220
 
221
+ - **Class:** `fastmcp.client.transports.UvxStdioTransport`
222
+ - **Inferred From:** Not automatically inferred
223
+ - **Use Case:** Running an MCP server packaged as a Python tool using [`uvx`](https://docs.astral.sh/uv/reference/cli/#uvx)
224
 
225
+ This is useful for executing MCP servers distributed as command-line tools or packages without installing them into your environment.
226
 
227
  ```python
228
+ from fastmcp import Client
229
  from fastmcp.client.transports import UvxStdioTransport
230
 
231
+ # Run a hypothetical 'cloud-analyzer-mcp' tool via uvx
 
232
  transport = UvxStdioTransport(
233
  tool_name="cloud-analyzer-mcp",
234
+ # from_package="cloud-analyzer-cli", # Optional: specify package if tool name differs
235
+ # with_packages=["boto3", "requests"] # Optional: add dependencies
 
236
  )
237
  client = Client(transport)
238
 
239
+ async def main():
240
+ async with client:
241
+ result = await client.call_tool("analyze_bucket", {"name": "my-data"})
242
+ print(f"Analysis result: {result}")
243
+
244
+ asyncio.run(main())
245
  ```
246
 
247
  ### NPX Stdio (Experimental)
248
 
249
+ - **Class:** `fastmcp.client.transports.NpxStdioTransport`
250
+ - **Inferred From:** Not automatically inferred
251
+ - **Use Case:** Running an MCP server packaged as an NPM package using `npx`
252
 
253
  Similar to `UvxStdioTransport`, but for the Node.js ecosystem.
254
 
255
  ```python
256
+ from fastmcp import Client
257
  from fastmcp.client.transports import NpxStdioTransport
258
 
259
+ # Run an MCP server from an NPM package
260
  transport = NpxStdioTransport(
261
+ package="mcp-server-package",
262
+ # args=["--port", "stdio"] # Optional: pass arguments to the package
263
  )
264
  client = Client(transport)
265
 
266
+ async def main():
267
+ async with client:
268
+ result = await client.call_tool("get_npm_data", {})
269
+ print(f"Result: {result}")
270
+
271
+ asyncio.run(main())
272
  ```
273
 
274
  ## In-Memory Transports
275
 
276
  ### FastMCP Transport
277
 
278
+ - **Class:** `fastmcp.client.transports.FastMCPTransport`
279
+ - **Inferred From:** An instance of `fastmcp.server.FastMCP`
280
+ - **Use Case:** Connecting directly to a `FastMCP` server instance in the same Python process
281
 
282
+ This is extremely useful for testing your FastMCP servers.
 
 
283
 
284
  ```python
285
  from fastmcp import FastMCP, Client
286
+ import asyncio
287
 
288
  # 1. Create your FastMCP server instance
289
  server = FastMCP(name="InMemoryServer")
290
+
291
  @server.tool()
292
+ def ping():
293
+ return "pong"
294
 
295
  # 2. Create a client pointing directly to the server instance
296
+ client = Client(server) # Transport is automatically inferred
 
 
 
 
 
297
 
298
+ async def main():
299
+ async with client:
300
+ result = await client.call_tool("ping")
301
+ print(f"In-memory call result: {result}")
 
302
 
303
+ asyncio.run(main())
304
  ```
 
 
 
305
 
306
+ Communication happens through efficient in-memory queues, making it very fast and ideal for unit testing.
 
 
 
docs/patterns/testing.mdx CHANGED
@@ -32,7 +32,7 @@ async def test_tool_functionality(mcp_server):
32
  # Pass the server directly to the Client constructor
33
  async with Client(mcp_server) as client:
34
  result = await client.call_tool("greet", {"name": "World"})
35
- assert "Hello, World!" in str(result[0])
36
  ```
37
 
38
  This pattern creates a direct connection between the client and server, allowing you to test your server's functionality efficiently.
 
32
  # Pass the server directly to the Client constructor
33
  async with Client(mcp_server) as client:
34
  result = await client.call_tool("greet", {"name": "World"})
35
+ assert result[0].text == "Hello, World!"
36
  ```
37
 
38
  This pattern creates a direct connection between the client and server, allowing you to test your server's functionality efficiently.
docs/servers/proxy.mdx CHANGED
@@ -89,7 +89,7 @@ proxy = FastMCP.from_client(client, name="SSE to Stdio Proxy")
89
  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.
90
 
91
  ```python
92
- from fastmcp import FastMCP
93
 
94
  # Original server
95
  original_server = FastMCP(name="Original")
@@ -98,9 +98,12 @@ original_server = FastMCP(name="Original")
98
  def tool_a() -> str:
99
  return "A"
100
 
101
- # Create a proxy of the original server
 
 
 
102
  proxy = FastMCP.from_client(
103
- original_server,
104
  name="Proxy Server"
105
  )
106
 
 
89
  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.
90
 
91
  ```python
92
+ from fastmcp import FastMCP, Client
93
 
94
  # Original server
95
  original_server = FastMCP(name="Original")
 
98
  def tool_a() -> str:
99
  return "A"
100
 
101
+ # To proxy an in-memory server, first create a Client to it.
102
+ client_to_original = Client(original_server)
103
+
104
+ # Create a proxy of the original server using the client.
105
  proxy = FastMCP.from_client(
106
+ client_to_original,
107
  name="Proxy Server"
108
  )
109
 
examples/in_memory_proxy_example.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ This example demonstrates how to set up and use an in-memory FastMCP proxy.
3
+
4
+ It illustrates the pattern:
5
+ 1. Create an original FastMCP server with some tools.
6
+ 2. Create a Client that connects to this original server (in-memory).
7
+ 3. Create a proxy FastMCP server using FastMCP.from_client(), passing it the client from step 2.
8
+ 4. Use another Client to connect to the proxy server (in-memory) and interact with the original server's tools through the proxy.
9
+ """
10
+
11
+ import asyncio
12
+
13
+ from mcp.types import TextContent
14
+
15
+ from fastmcp import FastMCP
16
+ from fastmcp.client import Client
17
+
18
+
19
+ class EchoService:
20
+ """A simple service to demonstrate with"""
21
+
22
+ def echo(self, message: str) -> str:
23
+ return f"Original server echoes: {message}"
24
+
25
+
26
+ async def main():
27
+ print("--- In-Memory FastMCP Proxy Example ---")
28
+ print("This example will walk through setting up an in-memory proxy.")
29
+ print("-----------------------------------------")
30
+
31
+ # 1. Original Server Setup
32
+ print(
33
+ "\nStep 1: Setting up the Original Server (OriginalEchoServer) with an 'echo' tool..."
34
+ )
35
+ original_server = FastMCP("OriginalEchoServer")
36
+ original_server.add_tool(EchoService().echo)
37
+ print(f" -> Original Server '{original_server.name}' created.")
38
+
39
+ # 2. Client for Proxy
40
+ print("\nStep 2: Creating a Client to connect to the Original Server...")
41
+ print(" (This client will be used internally by the proxy server)")
42
+ client_to_original = Client(original_server)
43
+ print(f" -> Client for proxy created, targeting '{original_server.name}'.")
44
+
45
+ # 3. Proxy Server Creation
46
+ print("\nStep 3: Creating the Proxy Server (InMemoryProxy)...")
47
+ print(
48
+ f" (Using FastMCP.from_client, passing it the client from Step 2 that targets '{original_server.name}')"
49
+ )
50
+ proxy_server = FastMCP.from_client(client_to_original, name="InMemoryProxy")
51
+ print(
52
+ f" -> Proxy Server '{proxy_server.name}' created, proxying '{original_server.name}'."
53
+ )
54
+
55
+ # 4. Interacting via Proxy
56
+ print("\nStep 4: Using a new Client to connect to the Proxy Server and interact...")
57
+ async with Client(proxy_server) as final_client:
58
+ print(f" -> Successfully connected to proxy '{proxy_server.name}'.")
59
+
60
+ print("\n Listing tools available via proxy...")
61
+ tools = await final_client.list_tools()
62
+ if tools:
63
+ print(" Available Tools:")
64
+ for tool in tools:
65
+ print(
66
+ f" - {tool.name} (Description: {tool.description or 'N/A'})"
67
+ )
68
+ else:
69
+ print(" No tools found via proxy.")
70
+
71
+ message_to_echo = "Hello, simplified proxied world!"
72
+ print(f"\n Calling 'echo' tool via proxy with message: '{message_to_echo}'")
73
+ try:
74
+ result = await final_client.call_tool("echo", {"message": message_to_echo})
75
+ if result and isinstance(result[0], TextContent):
76
+ print(f" Result from proxied 'echo' call: '{result[0].text}'")
77
+ else:
78
+ print(
79
+ f" Error: Unexpected result format from proxied 'echo' call: {result}"
80
+ )
81
+ except Exception as e:
82
+ print(f" Error calling 'echo' tool via proxy: {e}")
83
+
84
+ print("\n-----------------------------------------")
85
+ print("--- In-Memory Proxy Example Finished ---")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ asyncio.run(main())
pyproject.toml CHANGED
@@ -7,7 +7,7 @@ dependencies = [
7
  "python-dotenv>=1.1.0",
8
  "exceptiongroup>=1.2.2",
9
  "httpx>=0.28.1",
10
- "mcp>=1.8.2,<2.0.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
@@ -96,6 +96,7 @@ reportMissingTypeStubs = false
96
  useLibraryCodeForTypes = true
97
  venvPath = "."
98
  venv = ".venv"
 
99
 
100
  [tool.ruff.lint]
101
  extend-select = ["I", "UP"]
 
7
  "python-dotenv>=1.1.0",
8
  "exceptiongroup>=1.2.2",
9
  "httpx>=0.28.1",
10
+ "mcp>=1.9.0,<2.0.0",
11
  "openapi-pydantic>=0.5.1",
12
  "rich>=13.9.4",
13
  "typer>=0.15.2",
 
96
  useLibraryCodeForTypes = true
97
  venvPath = "."
98
  venv = ".venv"
99
+ strict = ["src/fastmcp/server/server.py"]
100
 
101
  [tool.ruff.lint]
102
  extend-select = ["I", "UP"]
src/fastmcp/cli/cli.py CHANGED
@@ -263,7 +263,7 @@ def dev(
263
  try:
264
  # Import server to get dependencies
265
  server = _import_server(file, server_object)
266
- if hasattr(server, "dependencies"):
267
  with_packages = list(set(with_packages + server.dependencies))
268
 
269
  env_vars = {}
 
263
  try:
264
  # Import server to get dependencies
265
  server = _import_server(file, server_object)
266
+ if hasattr(server, "dependencies") and server.dependencies is not None:
267
  with_packages = list(set(with_packages + server.dependencies))
268
 
269
  env_vars = {}
src/fastmcp/client/client.py CHANGED
@@ -1,9 +1,10 @@
1
  import datetime
2
- from contextlib import AbstractAsyncContextManager
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
6
  import mcp.types
 
7
  from mcp import ClientSession
8
  from pydantic import AnyUrl
9
 
@@ -14,8 +15,9 @@ from fastmcp.client.roots import (
14
  create_roots_callback,
15
  )
16
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
17
- from fastmcp.exceptions import ClientError
18
  from fastmcp.server import FastMCP
 
19
 
20
  from .transports import ClientTransport, SessionKwargs, infer_transport
21
 
@@ -33,8 +35,35 @@ class Client:
33
  """
34
  MCP client that delegates connection management to a Transport instance.
35
 
36
- The Client class is primarily concerned with MCP protocol logic,
37
- while the Transport handles connection establishment and management.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  """
39
 
40
  def __init__(
@@ -45,19 +74,22 @@ class Client:
45
  sampling_handler: SamplingHandler | None = None,
46
  log_handler: LogHandler | None = None,
47
  message_handler: MessageHandler | None = None,
48
- read_timeout_seconds: datetime.timedelta | None = None,
49
  ):
50
  self.transport = infer_transport(transport)
51
  self._session: ClientSession | None = None
52
- self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None
53
  self._nesting_counter: int = 0
54
 
 
 
 
55
  self._session_kwargs: SessionKwargs = {
56
  "sampling_callback": None,
57
  "list_roots_callback": None,
58
  "logging_callback": log_handler,
59
  "message_handler": message_handler,
60
- "read_timeout_seconds": read_timeout_seconds,
61
  }
62
 
63
  if roots is not None:
@@ -91,9 +123,23 @@ class Client:
91
 
92
  async def __aenter__(self):
93
  if self._nesting_counter == 0:
94
- # create new session
95
- self._session_cm = self.transport.connect_session(**self._session_kwargs)
96
- self._session = await self._session_cm.__aenter__()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
  self._nesting_counter += 1
99
  return self
@@ -101,10 +147,14 @@ class Client:
101
  async def __aexit__(self, exc_type, exc_val, exc_tb):
102
  self._nesting_counter -= 1
103
 
104
- if self._nesting_counter == 0 and self._session_cm is not None:
105
- await self._session_cm.__aexit__(exc_type, exc_val, exc_tb)
106
- self._session_cm = None
107
- self._session = None
 
 
 
 
108
 
109
  # --- MCP Client Methods ---
110
 
@@ -118,9 +168,12 @@ class Client:
118
  progress_token: str | int,
119
  progress: float,
120
  total: float | None = None,
 
121
  ) -> None:
122
  """Send a progress notification."""
123
- await self.session.send_progress_notification(progress_token, progress, total)
 
 
124
 
125
  async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
126
  """Send a logging/setLevel request."""
@@ -377,7 +430,10 @@ class Client:
377
  # --- Call Tool ---
378
 
379
  async def call_tool_mcp(
380
- self, name: str, arguments: dict[str, Any]
 
 
 
381
  ) -> mcp.types.CallToolResult:
382
  """Send a tools/call request and return the complete MCP protocol result.
383
 
@@ -387,7 +443,7 @@ class Client:
387
  Args:
388
  name (str): The name of the tool to call.
389
  arguments (dict[str, Any]): Arguments to pass to the tool.
390
-
391
  Returns:
392
  mcp.types.CallToolResult: The complete response object from the protocol,
393
  containing the tool result and any additional metadata.
@@ -395,19 +451,25 @@ class Client:
395
  Raises:
396
  RuntimeError: If called while the client is not connected.
397
  """
398
- result = await self.session.call_tool(name=name, arguments=arguments)
 
 
 
 
 
399
  return result
400
 
401
  async def call_tool(
402
  self,
403
  name: str,
404
  arguments: dict[str, Any] | None = None,
 
405
  ) -> list[
406
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
407
  ]:
408
  """Call a tool on the server.
409
 
410
- Unlike call_tool_mcp, this method raises a ClientError if the tool call results in an error.
411
 
412
  Args:
413
  name (str): The name of the tool to call.
@@ -418,11 +480,15 @@ class Client:
418
  The content returned by the tool.
419
 
420
  Raises:
421
- ClientError: If the tool call results in an error.
422
  RuntimeError: If called while the client is not connected.
423
  """
424
- result = await self.call_tool_mcp(name=name, arguments=arguments or {})
 
 
 
 
425
  if result.isError:
426
  msg = cast(mcp.types.TextContent, result.content[0]).text
427
- raise ClientError(msg)
428
  return result.content
 
1
  import datetime
2
+ from contextlib import AsyncExitStack
3
  from pathlib import Path
4
  from typing import Any, cast
5
 
6
  import mcp.types
7
+ from exceptiongroup import catch
8
  from mcp import ClientSession
9
  from pydantic import AnyUrl
10
 
 
15
  create_roots_callback,
16
  )
17
  from fastmcp.client.sampling import SamplingHandler, create_sampling_callback
18
+ from fastmcp.exceptions import ToolError
19
  from fastmcp.server import FastMCP
20
+ from fastmcp.utilities.exceptions import get_catch_handlers
21
 
22
  from .transports import ClientTransport, SessionKwargs, infer_transport
23
 
 
35
  """
36
  MCP client that delegates connection management to a Transport instance.
37
 
38
+ The Client class is responsible for MCP protocol logic, while the Transport
39
+ handles connection establishment and management. Client provides methods
40
+ for working with resources, prompts, tools and other MCP capabilities.
41
+
42
+ Args:
43
+ transport: Connection source specification, which can be:
44
+ - ClientTransport: Direct transport instance
45
+ - FastMCP: In-process FastMCP server
46
+ - AnyUrl | str: URL to connect to
47
+ - Path: File path for local socket
48
+ - dict: Transport configuration
49
+ roots: Optional RootsList or RootsHandler for filesystem access
50
+ sampling_handler: Optional handler for sampling requests
51
+ log_handler: Optional handler for log messages
52
+ message_handler: Optional handler for protocol messages
53
+ timeout: Optional timeout for requests (seconds or timedelta)
54
+
55
+ Examples:
56
+ ```python
57
+ # Connect to FastMCP server
58
+ client = Client("http://localhost:8080")
59
+
60
+ async with client:
61
+ # List available resources
62
+ resources = await client.list_resources()
63
+
64
+ # Call a tool
65
+ result = await client.call_tool("my_tool", {"param": "value"})
66
+ ```
67
  """
68
 
69
  def __init__(
 
74
  sampling_handler: SamplingHandler | None = None,
75
  log_handler: LogHandler | None = None,
76
  message_handler: MessageHandler | None = None,
77
+ timeout: datetime.timedelta | float | int | None = None,
78
  ):
79
  self.transport = infer_transport(transport)
80
  self._session: ClientSession | None = None
81
+ self._exit_stack: AsyncExitStack | None = None
82
  self._nesting_counter: int = 0
83
 
84
+ if isinstance(timeout, int | float):
85
+ timeout = datetime.timedelta(seconds=timeout)
86
+
87
  self._session_kwargs: SessionKwargs = {
88
  "sampling_callback": None,
89
  "list_roots_callback": None,
90
  "logging_callback": log_handler,
91
  "message_handler": message_handler,
92
+ "read_timeout_seconds": timeout,
93
  }
94
 
95
  if roots is not None:
 
123
 
124
  async def __aenter__(self):
125
  if self._nesting_counter == 0:
126
+ # Create exit stack to manage both context managers
127
+ stack = AsyncExitStack()
128
+ await stack.__aenter__()
129
+
130
+ # Add the exception handling context
131
+ stack.enter_context(catch(get_catch_handlers()))
132
+
133
+ # the above catch will only apply once this __aenter__ finishes so
134
+ # we need to wrap the session creation in a new context in case it
135
+ # raises errors itself
136
+ with catch(get_catch_handlers()):
137
+ # Create and enter the transport session using the exit stack
138
+ session_cm = self.transport.connect_session(**self._session_kwargs)
139
+ self._session = await stack.enter_async_context(session_cm)
140
+
141
+ # Store the stack for cleanup in __aexit__
142
+ self._exit_stack = stack
143
 
144
  self._nesting_counter += 1
145
  return self
 
147
  async def __aexit__(self, exc_type, exc_val, exc_tb):
148
  self._nesting_counter -= 1
149
 
150
+ if self._nesting_counter == 0:
151
+ # Exit the stack which will handle cleaning up the session
152
+ if self._exit_stack is not None:
153
+ try:
154
+ await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb)
155
+ finally:
156
+ self._exit_stack = None
157
+ self._session = None
158
 
159
  # --- MCP Client Methods ---
160
 
 
168
  progress_token: str | int,
169
  progress: float,
170
  total: float | None = None,
171
+ message: str | None = None,
172
  ) -> None:
173
  """Send a progress notification."""
174
+ await self.session.send_progress_notification(
175
+ progress_token, progress, total, message
176
+ )
177
 
178
  async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None:
179
  """Send a logging/setLevel request."""
 
430
  # --- Call Tool ---
431
 
432
  async def call_tool_mcp(
433
+ self,
434
+ name: str,
435
+ arguments: dict[str, Any],
436
+ timeout: datetime.timedelta | float | int | None = None,
437
  ) -> mcp.types.CallToolResult:
438
  """Send a tools/call request and return the complete MCP protocol result.
439
 
 
443
  Args:
444
  name (str): The name of the tool to call.
445
  arguments (dict[str, Any]): Arguments to pass to the tool.
446
+ timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None.
447
  Returns:
448
  mcp.types.CallToolResult: The complete response object from the protocol,
449
  containing the tool result and any additional metadata.
 
451
  Raises:
452
  RuntimeError: If called while the client is not connected.
453
  """
454
+
455
+ if isinstance(timeout, int | float):
456
+ timeout = datetime.timedelta(seconds=timeout)
457
+ result = await self.session.call_tool(
458
+ name=name, arguments=arguments, read_timeout_seconds=timeout
459
+ )
460
  return result
461
 
462
  async def call_tool(
463
  self,
464
  name: str,
465
  arguments: dict[str, Any] | None = None,
466
+ timeout: datetime.timedelta | float | int | None = None,
467
  ) -> list[
468
  mcp.types.TextContent | mcp.types.ImageContent | mcp.types.EmbeddedResource
469
  ]:
470
  """Call a tool on the server.
471
 
472
+ Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error.
473
 
474
  Args:
475
  name (str): The name of the tool to call.
 
480
  The content returned by the tool.
481
 
482
  Raises:
483
+ ToolError: If the tool call results in an error.
484
  RuntimeError: If called while the client is not connected.
485
  """
486
+ result = await self.call_tool_mcp(
487
+ name=name,
488
+ arguments=arguments or {},
489
+ timeout=timeout,
490
+ )
491
  if result.isError:
492
  msg = cast(mcp.types.TextContent, result.content[0]).text
493
+ raise ToolError(msg)
494
  return result.content
src/fastmcp/client/transports.py CHANGED
@@ -8,10 +8,9 @@ import sys
8
  import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
- from typing import Any, TypedDict
12
 
13
- from exceptiongroup import BaseExceptionGroup, catch
14
- from mcp import ClientSession, McpError, StdioServerParameters
15
  from mcp.client.session import (
16
  ListRootsFnT,
17
  LoggingFnT,
@@ -26,7 +25,6 @@ from mcp.shared.memory import create_connected_server_and_client_session
26
  from pydantic import AnyUrl
27
  from typing_extensions import Unpack
28
 
29
- from fastmcp.exceptions import ClientError
30
  from fastmcp.server import FastMCP as FastMCPServer
31
 
32
 
@@ -104,7 +102,12 @@ class WSTransport(ClientTransport):
104
  class SSETransport(ClientTransport):
105
  """Transport implementation that connects to an MCP server via Server-Sent Events."""
106
 
107
- def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
 
 
 
 
 
108
  if isinstance(url, AnyUrl):
109
  url = str(url)
110
  if not isinstance(url, str) or not url.startswith("http"):
@@ -112,11 +115,28 @@ class SSETransport(ClientTransport):
112
  self.url = url
113
  self.headers = headers or {}
114
 
 
 
 
 
115
  @contextlib.asynccontextmanager
116
  async def connect_session(
117
  self, **session_kwargs: Unpack[SessionKwargs]
118
  ) -> AsyncIterator[ClientSession]:
119
- async with sse_client(self.url, headers=self.headers) as transport:
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  read_stream, write_stream = transport
121
  async with ClientSession(
122
  read_stream, write_stream, **session_kwargs
@@ -131,7 +151,12 @@ class SSETransport(ClientTransport):
131
  class StreamableHttpTransport(ClientTransport):
132
  """Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
133
 
134
- def __init__(self, url: str | AnyUrl, headers: dict[str, str] | None = None):
 
 
 
 
 
135
  if isinstance(url, AnyUrl):
136
  url = str(url)
137
  if not isinstance(url, str) or not url.startswith("http"):
@@ -139,11 +164,25 @@ class StreamableHttpTransport(ClientTransport):
139
  self.url = url
140
  self.headers = headers or {}
141
 
 
 
 
 
142
  @contextlib.asynccontextmanager
143
  async def connect_session(
144
  self, **session_kwargs: Unpack[SessionKwargs]
145
  ) -> AsyncIterator[ClientSession]:
146
- async with streamablehttp_client(self.url, headers=self.headers) as transport:
 
 
 
 
 
 
 
 
 
 
147
  read_stream, write_stream, _ = transport
148
  async with ClientSession(
149
  read_stream, write_stream, **session_kwargs
@@ -418,26 +457,12 @@ class FastMCPTransport(ClientTransport):
418
  async def connect_session(
419
  self, **session_kwargs: Unpack[SessionKwargs]
420
  ) -> AsyncIterator[ClientSession]:
421
- def exception_handler(excgroup: BaseExceptionGroup):
422
- for exc in excgroup.exceptions:
423
- if isinstance(exc, BaseExceptionGroup):
424
- exception_handler(exc)
425
- raise exc
426
-
427
- def mcperror_handler(excgroup: BaseExceptionGroup):
428
- for exc in excgroup.exceptions:
429
- if isinstance(exc, BaseExceptionGroup):
430
- mcperror_handler(exc)
431
- raise ClientError(exc)
432
-
433
- # backport of 3.11's except* syntax
434
- with catch({McpError: mcperror_handler, Exception: exception_handler}):
435
- # create_connected_server_and_client_session manages the session lifecycle itself
436
- async with create_connected_server_and_client_session(
437
- server=self._fastmcp._mcp_server,
438
- **session_kwargs,
439
- ) as session:
440
- yield session
441
 
442
  def __repr__(self) -> str:
443
  return f"<FastMCP(server='{self._fastmcp.name}')>"
@@ -519,12 +544,6 @@ def infer_transport(
519
  headers=server.get("headers", None),
520
  )
521
 
522
- # WebSocket transport
523
- elif "ws_url" in server:
524
- return WSTransport(
525
- url=server["ws_url"],
526
- )
527
-
528
  raise ValueError("Cannot determine transport type from dictionary")
529
 
530
  # the transport is an unknown type
 
8
  import warnings
9
  from collections.abc import AsyncIterator
10
  from pathlib import Path
11
+ from typing import Any, TypedDict, cast
12
 
13
+ from mcp import ClientSession, StdioServerParameters
 
14
  from mcp.client.session import (
15
  ListRootsFnT,
16
  LoggingFnT,
 
25
  from pydantic import AnyUrl
26
  from typing_extensions import Unpack
27
 
 
28
  from fastmcp.server import FastMCP as FastMCPServer
29
 
30
 
 
102
  class SSETransport(ClientTransport):
103
  """Transport implementation that connects to an MCP server via Server-Sent Events."""
104
 
105
+ def __init__(
106
+ self,
107
+ url: str | AnyUrl,
108
+ headers: dict[str, str] | None = None,
109
+ sse_read_timeout: datetime.timedelta | float | int | None = None,
110
+ ):
111
  if isinstance(url, AnyUrl):
112
  url = str(url)
113
  if not isinstance(url, str) or not url.startswith("http"):
 
115
  self.url = url
116
  self.headers = headers or {}
117
 
118
+ if isinstance(sse_read_timeout, int | float):
119
+ sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
120
+ self.sse_read_timeout = sse_read_timeout
121
+
122
  @contextlib.asynccontextmanager
123
  async def connect_session(
124
  self, **session_kwargs: Unpack[SessionKwargs]
125
  ) -> AsyncIterator[ClientSession]:
126
+ client_kwargs = {}
127
+ # sse_read_timeout has a default value set, so we can't pass None without overriding it
128
+ # instead we simply leave the kwarg out if it's not provided
129
+ if self.sse_read_timeout is not None:
130
+ client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds()
131
+ if session_kwargs.get("read_timeout_seconds", None) is not None:
132
+ read_timeout_seconds = cast(
133
+ datetime.timedelta, session_kwargs.get("read_timeout_seconds")
134
+ )
135
+ client_kwargs["timeout"] = read_timeout_seconds.total_seconds()
136
+
137
+ async with sse_client(
138
+ self.url, headers=self.headers, **client_kwargs
139
+ ) as transport:
140
  read_stream, write_stream = transport
141
  async with ClientSession(
142
  read_stream, write_stream, **session_kwargs
 
151
  class StreamableHttpTransport(ClientTransport):
152
  """Transport implementation that connects to an MCP server via Streamable HTTP Requests."""
153
 
154
+ def __init__(
155
+ self,
156
+ url: str | AnyUrl,
157
+ headers: dict[str, str] | None = None,
158
+ sse_read_timeout: datetime.timedelta | float | int | None = None,
159
+ ):
160
  if isinstance(url, AnyUrl):
161
  url = str(url)
162
  if not isinstance(url, str) or not url.startswith("http"):
 
164
  self.url = url
165
  self.headers = headers or {}
166
 
167
+ if isinstance(sse_read_timeout, int | float):
168
+ sse_read_timeout = datetime.timedelta(seconds=sse_read_timeout)
169
+ self.sse_read_timeout = sse_read_timeout
170
+
171
  @contextlib.asynccontextmanager
172
  async def connect_session(
173
  self, **session_kwargs: Unpack[SessionKwargs]
174
  ) -> AsyncIterator[ClientSession]:
175
+ client_kwargs = {}
176
+ # sse_read_timeout has a default value set, so we can't pass None without overriding it
177
+ # instead we simply leave the kwarg out if it's not provided
178
+ if self.sse_read_timeout is not None:
179
+ client_kwargs["sse_read_timeout"] = self.sse_read_timeout
180
+ if session_kwargs.get("read_timeout_seconds", None) is not None:
181
+ client_kwargs["timeout"] = session_kwargs.get("read_timeout_seconds")
182
+
183
+ async with streamablehttp_client(
184
+ self.url, headers=self.headers, **client_kwargs
185
+ ) as transport:
186
  read_stream, write_stream, _ = transport
187
  async with ClientSession(
188
  read_stream, write_stream, **session_kwargs
 
457
  async def connect_session(
458
  self, **session_kwargs: Unpack[SessionKwargs]
459
  ) -> AsyncIterator[ClientSession]:
460
+ # create_connected_server_and_client_session manages the session lifecycle itself
461
+ async with create_connected_server_and_client_session(
462
+ server=self._fastmcp._mcp_server,
463
+ **session_kwargs,
464
+ ) as session:
465
+ yield session
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
  def __repr__(self) -> str:
468
  return f"<FastMCP(server='{self._fastmcp.name}')>"
 
544
  headers=server.get("headers", None),
545
  )
546
 
 
 
 
 
 
 
547
  raise ValueError("Cannot determine transport type from dictionary")
548
 
549
  # the transport is an unknown type
src/fastmcp/exceptions.py CHANGED
@@ -1,5 +1,7 @@
1
  """Custom exceptions for FastMCP."""
2
 
 
 
3
 
4
  class FastMCPError(Exception):
5
  """Base error for FastMCP."""
 
1
  """Custom exceptions for FastMCP."""
2
 
3
+ from mcp import McpError # noqa: F401
4
+
5
 
6
  class FastMCPError(Exception):
7
  """Base error for FastMCP."""
src/fastmcp/server/context.py CHANGED
@@ -56,7 +56,7 @@ class Context:
56
  ctx.error("Error message")
57
 
58
  # Report progress
59
- ctx.report_progress(50, 100)
60
 
61
  # Access resources
62
  data = ctx.read_resource("resource://data")
@@ -96,7 +96,7 @@ class Context:
96
  return self.fastmcp._mcp_server.request_context
97
 
98
  async def report_progress(
99
- self, progress: float, total: float | None = None
100
  ) -> None:
101
  """Report progress for the current operation.
102
 
@@ -115,7 +115,10 @@ class Context:
115
  return
116
 
117
  await self.request_context.session.send_progress_notification(
118
- progress_token=progress_token, progress=progress, total=total
 
 
 
119
  )
120
 
121
  async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
 
56
  ctx.error("Error message")
57
 
58
  # Report progress
59
+ ctx.report_progress(50, 100, "Processing")
60
 
61
  # Access resources
62
  data = ctx.read_resource("resource://data")
 
96
  return self.fastmcp._mcp_server.request_context
97
 
98
  async def report_progress(
99
+ self, progress: float, total: float | None = None, message: str | None = None
100
  ) -> None:
101
  """Report progress for the current operation.
102
 
 
115
  return
116
 
117
  await self.request_context.session.send_progress_notification(
118
+ progress_token=progress_token,
119
+ progress=progress,
120
+ total=total,
121
+ message=message,
122
  )
123
 
124
  async def read_resource(self, uri: str | AnyUrl) -> list[ReadResourceContents]:
src/fastmcp/server/http.py CHANGED
@@ -10,9 +10,15 @@ from mcp.server.auth.middleware.bearer_auth import (
10
  BearerAuthBackend,
11
  RequireAuthMiddleware,
12
  )
13
- from mcp.server.auth.provider import OAuthAuthorizationServerProvider
 
 
 
 
 
14
  from mcp.server.auth.routes import create_auth_routes
15
  from mcp.server.auth.settings import AuthSettings
 
16
  from mcp.server.sse import SseServerTransport
17
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
18
  from starlette.applications import Starlette
@@ -30,6 +36,7 @@ if TYPE_CHECKING:
30
 
31
  logger = get_logger(__name__)
32
 
 
33
  _current_http_request: ContextVar[Request | None] = ContextVar(
34
  "http_request",
35
  default=None,
@@ -62,7 +69,10 @@ class RequestContextMiddleware:
62
 
63
 
64
  def setup_auth_middleware_and_routes(
65
- auth_server_provider: OAuthAuthorizationServerProvider | None,
 
 
 
66
  auth_settings: AuthSettings | None,
67
  ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
68
  """Set up authentication middleware and routes if auth is enabled.
@@ -136,10 +146,13 @@ def create_base_app(
136
 
137
 
138
  def create_sse_app(
139
- server: FastMCP,
140
  message_path: str,
141
  sse_path: str,
142
- auth_server_provider: OAuthAuthorizationServerProvider | None = None,
 
 
 
143
  auth_settings: AuthSettings | None = None,
144
  debug: bool = False,
145
  routes: list[BaseRoute] | None = None,
@@ -236,10 +249,13 @@ def create_sse_app(
236
 
237
 
238
  def create_streamable_http_app(
239
- server: FastMCP,
240
  streamable_http_path: str,
241
  event_store: None = None,
242
- auth_server_provider: OAuthAuthorizationServerProvider | None = None,
 
 
 
243
  auth_settings: AuthSettings | None = None,
244
  json_response: bool = False,
245
  stateless_http: bool = False,
 
10
  BearerAuthBackend,
11
  RequireAuthMiddleware,
12
  )
13
+ from mcp.server.auth.provider import (
14
+ AccessTokenT,
15
+ AuthorizationCodeT,
16
+ OAuthAuthorizationServerProvider,
17
+ RefreshTokenT,
18
+ )
19
  from mcp.server.auth.routes import create_auth_routes
20
  from mcp.server.auth.settings import AuthSettings
21
+ from mcp.server.lowlevel.server import LifespanResultT
22
  from mcp.server.sse import SseServerTransport
23
  from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
24
  from starlette.applications import Starlette
 
36
 
37
  logger = get_logger(__name__)
38
 
39
+
40
  _current_http_request: ContextVar[Request | None] = ContextVar(
41
  "http_request",
42
  default=None,
 
69
 
70
 
71
  def setup_auth_middleware_and_routes(
72
+ auth_server_provider: OAuthAuthorizationServerProvider[
73
+ AuthorizationCodeT, RefreshTokenT, AccessTokenT
74
+ ]
75
+ | None,
76
  auth_settings: AuthSettings | None,
77
  ) -> tuple[list[Middleware], list[BaseRoute], list[str]]:
78
  """Set up authentication middleware and routes if auth is enabled.
 
146
 
147
 
148
  def create_sse_app(
149
+ server: FastMCP[LifespanResultT],
150
  message_path: str,
151
  sse_path: str,
152
+ auth_server_provider: OAuthAuthorizationServerProvider[
153
+ AuthorizationCodeT, RefreshTokenT, AccessTokenT
154
+ ]
155
+ | None = None,
156
  auth_settings: AuthSettings | None = None,
157
  debug: bool = False,
158
  routes: list[BaseRoute] | None = None,
 
249
 
250
 
251
  def create_streamable_http_app(
252
+ server: FastMCP[LifespanResultT],
253
  streamable_http_path: str,
254
  event_store: None = None,
255
+ auth_server_provider: OAuthAuthorizationServerProvider[
256
+ AuthorizationCodeT, RefreshTokenT, AccessTokenT
257
+ ]
258
+ | None = None,
259
  auth_settings: AuthSettings | None = None,
260
  json_response: bool = False,
261
  stateless_http: bool = False,
src/fastmcp/server/server.py CHANGED
@@ -66,7 +66,7 @@ DuplicateBehavior = Literal["warn", "error", "replace", "ignore"]
66
 
67
 
68
  @asynccontextmanager
69
- async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]:
70
  """Default lifespan context manager that does nothing.
71
 
72
  Args:
@@ -79,8 +79,10 @@ async def default_lifespan(server: FastMCP) -> AsyncIterator[Any]:
79
 
80
 
81
  def _lifespan_wrapper(
82
- app: FastMCP,
83
- lifespan: Callable[[FastMCP], AbstractAsyncContextManager[LifespanResultT]],
 
 
84
  ) -> Callable[
85
  [MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
86
  ]:
@@ -189,15 +191,13 @@ class FastMCP(Generic[LifespanResultT]):
189
  """
190
  if transport is None:
191
  transport = "stdio"
192
- if transport not in ["stdio", "streamable-http", "sse"]:
193
  raise ValueError(f"Unknown transport: {transport}")
194
 
195
  if transport == "stdio":
196
  await self.run_stdio_async(**transport_kwargs)
197
- elif transport == "streamable-http":
198
- await self.run_http_async(transport="streamable-http", **transport_kwargs)
199
- elif transport == "sse":
200
- await self.run_http_async(transport="sse", **transport_kwargs)
201
  else:
202
  raise ValueError(f"Unknown transport: {transport}")
203
 
@@ -228,7 +228,7 @@ class FastMCP(Generic[LifespanResultT]):
228
  async def get_tools(self) -> dict[str, Tool]:
229
  """Get all registered tools, indexed by registered key."""
230
  if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
231
- tools = {}
232
  for server in self._mounted_servers.values():
233
  server_tools = await server.get_tools()
234
  tools.update(server_tools)
@@ -239,7 +239,7 @@ class FastMCP(Generic[LifespanResultT]):
239
  async def get_resources(self) -> dict[str, Resource]:
240
  """Get all registered resources, indexed by registered key."""
241
  if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
242
- resources = {}
243
  for server in self._mounted_servers.values():
244
  server_resources = await server.get_resources()
245
  resources.update(server_resources)
@@ -252,7 +252,7 @@ class FastMCP(Generic[LifespanResultT]):
252
  if (
253
  templates := self._cache.get("resource_templates")
254
  ) is self._cache.NOT_FOUND:
255
- templates = {}
256
  for server in self._mounted_servers.values():
257
  server_templates = await server.get_resource_templates()
258
  templates.update(server_templates)
@@ -265,7 +265,7 @@ class FastMCP(Generic[LifespanResultT]):
265
  List all available prompts.
266
  """
267
  if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
268
- prompts = {}
269
  for server in self._mounted_servers.values():
270
  server_prompts = await server.get_prompts()
271
  prompts.update(server_prompts)
@@ -418,7 +418,7 @@ class FastMCP(Generic[LifespanResultT]):
418
  for server in self._mounted_servers.values():
419
  if server.match_prompt(name):
420
  new_key = server.strip_prompt_prefix(name)
421
- return await server.server._mcp_get_prompt(new_key, arguments)
422
  else:
423
  raise NotFoundError(f"Unknown prompt: {name}")
424
 
@@ -743,7 +743,8 @@ class FastMCP(Generic[LifespanResultT]):
743
  port: int | None = None,
744
  log_level: str | None = None,
745
  path: str | None = None,
746
- uvicorn_config: dict | None = None,
 
747
  ) -> None:
748
  """Run the server using HTTP transport.
749
 
@@ -760,7 +761,7 @@ class FastMCP(Generic[LifespanResultT]):
760
  # lifespan is required for streamable http
761
  uvicorn_config["lifespan"] = "on"
762
 
763
- app = self.http_app(path=path, transport=transport)
764
 
765
  config = uvicorn.Config(
766
  app,
@@ -779,7 +780,7 @@ class FastMCP(Generic[LifespanResultT]):
779
  log_level: str | None = None,
780
  path: str | None = None,
781
  message_path: str | None = None,
782
- uvicorn_config: dict | None = None,
783
  ) -> None:
784
  """Run the server using SSE transport."""
785
 
@@ -901,7 +902,7 @@ class FastMCP(Generic[LifespanResultT]):
901
  port: int | None = None,
902
  log_level: str | None = None,
903
  path: str | None = None,
904
- uvicorn_config: dict | None = None,
905
  ) -> None:
906
  # Deprecated since 2.3.2
907
  warnings.warn(
@@ -1128,7 +1129,7 @@ class MountedServer:
1128
  def __init__(
1129
  self,
1130
  prefix: str,
1131
- server: FastMCP,
1132
  tool_separator: str | None = None,
1133
  resource_separator: str | None = None,
1134
  prompt_separator: str | None = None,
 
66
 
67
 
68
  @asynccontextmanager
69
+ async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]:
70
  """Default lifespan context manager that does nothing.
71
 
72
  Args:
 
79
 
80
 
81
  def _lifespan_wrapper(
82
+ app: FastMCP[LifespanResultT],
83
+ lifespan: Callable[
84
+ [FastMCP[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
85
+ ],
86
  ) -> Callable[
87
  [MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]
88
  ]:
 
191
  """
192
  if transport is None:
193
  transport = "stdio"
194
+ if transport not in {"stdio", "streamable-http", "sse"}:
195
  raise ValueError(f"Unknown transport: {transport}")
196
 
197
  if transport == "stdio":
198
  await self.run_stdio_async(**transport_kwargs)
199
+ elif transport in {"streamable-http", "sse"}:
200
+ await self.run_http_async(transport=transport, **transport_kwargs)
 
 
201
  else:
202
  raise ValueError(f"Unknown transport: {transport}")
203
 
 
228
  async def get_tools(self) -> dict[str, Tool]:
229
  """Get all registered tools, indexed by registered key."""
230
  if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
231
+ tools: dict[str, Tool] = {}
232
  for server in self._mounted_servers.values():
233
  server_tools = await server.get_tools()
234
  tools.update(server_tools)
 
239
  async def get_resources(self) -> dict[str, Resource]:
240
  """Get all registered resources, indexed by registered key."""
241
  if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
242
+ resources: dict[str, Resource] = {}
243
  for server in self._mounted_servers.values():
244
  server_resources = await server.get_resources()
245
  resources.update(server_resources)
 
252
  if (
253
  templates := self._cache.get("resource_templates")
254
  ) is self._cache.NOT_FOUND:
255
+ templates: dict[str, ResourceTemplate] = {}
256
  for server in self._mounted_servers.values():
257
  server_templates = await server.get_resource_templates()
258
  templates.update(server_templates)
 
265
  List all available prompts.
266
  """
267
  if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
268
+ prompts: dict[str, Prompt] = {}
269
  for server in self._mounted_servers.values():
270
  server_prompts = await server.get_prompts()
271
  prompts.update(server_prompts)
 
418
  for server in self._mounted_servers.values():
419
  if server.match_prompt(name):
420
  new_key = server.strip_prompt_prefix(name)
421
+ return await server.server._mcp_get_prompt(new_key, arguments)
422
  else:
423
  raise NotFoundError(f"Unknown prompt: {name}")
424
 
 
743
  port: int | None = None,
744
  log_level: str | None = None,
745
  path: str | None = None,
746
+ uvicorn_config: dict[str, Any] | None = None,
747
+ middleware: list[Middleware] | None = None,
748
  ) -> None:
749
  """Run the server using HTTP transport.
750
 
 
761
  # lifespan is required for streamable http
762
  uvicorn_config["lifespan"] = "on"
763
 
764
+ app = self.http_app(path=path, transport=transport, middleware=middleware)
765
 
766
  config = uvicorn.Config(
767
  app,
 
780
  log_level: str | None = None,
781
  path: str | None = None,
782
  message_path: str | None = None,
783
+ uvicorn_config: dict[str, Any] | None = None,
784
  ) -> None:
785
  """Run the server using SSE transport."""
786
 
 
902
  port: int | None = None,
903
  log_level: str | None = None,
904
  path: str | None = None,
905
+ uvicorn_config: dict[str, Any] | None = None,
906
  ) -> None:
907
  # Deprecated since 2.3.2
908
  warnings.warn(
 
1129
  def __init__(
1130
  self,
1131
  prefix: str,
1132
+ server: FastMCP[LifespanResultT],
1133
  tool_separator: str | None = None,
1134
  resource_separator: str | None = None,
1135
  prompt_separator: str | None = None,
src/fastmcp/settings.py CHANGED
@@ -1,6 +1,7 @@
1
  from __future__ import annotations as _annotations
2
 
3
- from typing import TYPE_CHECKING, Literal
 
4
 
5
  from mcp.server.auth.settings import AuthSettings
6
  from pydantic import Field, model_validator
@@ -28,16 +29,37 @@ class Settings(BaseSettings):
28
 
29
  test_mode: bool = False
30
  log_level: LOG_LEVEL = "INFO"
31
- tool_attempt_parse_json_args: bool = Field(
32
- default=False,
33
- description="""
34
- Note: this enables a legacy behavior. If True, will attempt to parse
35
- stringified JSON lists and objects strings in tool arguments before
36
- passing them to the tool. This is an old behavior that can create
37
- unexpected type coercion issues, but may be helpful for less powerful
38
- LLMs that stringify JSON instead of passing actual lists and objects.
39
- Defaults to False.""",
40
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  @model_validator(mode="after")
43
  def setup_logging(self) -> Self:
@@ -64,7 +86,10 @@ class ServerSettings(BaseSettings):
64
  nested_model_default_partial_update=True,
65
  )
66
 
67
- log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
 
 
 
68
 
69
  # HTTP settings
70
  host: str = "127.0.0.1"
@@ -83,10 +108,13 @@ class ServerSettings(BaseSettings):
83
  # prompt settings
84
  on_duplicate_prompts: DuplicateBehavior = "warn"
85
 
86
- dependencies: list[str] = Field(
87
- default_factory=list,
88
- description="List of dependencies to install in the server environment",
89
- )
 
 
 
90
 
91
  # cache settings (for checking mounted servers)
92
  cache_expiration_seconds: float = 0
@@ -100,16 +128,4 @@ class ServerSettings(BaseSettings):
100
  )
101
 
102
 
103
- class ClientSettings(BaseSettings):
104
- """FastMCP client settings."""
105
-
106
- model_config = SettingsConfigDict(
107
- env_prefix="FASTMCP_CLIENT_",
108
- env_file=".env",
109
- extra="ignore",
110
- )
111
-
112
- log_level: LOG_LEVEL = Field(default_factory=lambda: Settings().log_level)
113
-
114
-
115
  settings = Settings()
 
1
  from __future__ import annotations as _annotations
2
 
3
+ import inspect
4
+ from typing import TYPE_CHECKING, Annotated, Literal
5
 
6
  from mcp.server.auth.settings import AuthSettings
7
  from pydantic import Field, model_validator
 
29
 
30
  test_mode: bool = False
31
  log_level: LOG_LEVEL = "INFO"
32
+ client_raise_first_exceptiongroup_error: Annotated[
33
+ bool,
34
+ Field(
35
+ default=True,
36
+ description=inspect.cleandoc(
37
+ """
38
+ Many MCP components operate in anyio taskgroups, and raise
39
+ ExceptionGroups instead of exceptions. If this setting is True, FastMCP Clients
40
+ will `raise` the first error in any ExceptionGroup instead of raising
41
+ the ExceptionGroup as a whole. This is useful for debugging, but may
42
+ mask other errors.
43
+ """
44
+ ),
45
+ ),
46
+ ] = True
47
+ tool_attempt_parse_json_args: Annotated[
48
+ bool,
49
+ Field(
50
+ default=False,
51
+ description=inspect.cleandoc(
52
+ """
53
+ Note: this enables a legacy behavior. If True, will attempt to parse
54
+ stringified JSON lists and objects strings in tool arguments before
55
+ passing them to the tool. This is an old behavior that can create
56
+ unexpected type coercion issues, but may be helpful for less powerful
57
+ LLMs that stringify JSON instead of passing actual lists and objects.
58
+ Defaults to False.
59
+ """
60
+ ),
61
+ ),
62
+ ] = False
63
 
64
  @model_validator(mode="after")
65
  def setup_logging(self) -> Self:
 
86
  nested_model_default_partial_update=True,
87
  )
88
 
89
+ log_level: Annotated[
90
+ LOG_LEVEL,
91
+ Field(default_factory=lambda: Settings().log_level),
92
+ ]
93
 
94
  # HTTP settings
95
  host: str = "127.0.0.1"
 
108
  # prompt settings
109
  on_duplicate_prompts: DuplicateBehavior = "warn"
110
 
111
+ dependencies: Annotated[
112
+ list[str],
113
+ Field(
114
+ default_factory=list,
115
+ description="List of dependencies to install in the server environment",
116
+ ),
117
+ ] = []
118
 
119
  # cache settings (for checking mounted servers)
120
  cache_expiration_seconds: float = 0
 
128
  )
129
 
130
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  settings = Settings()
src/fastmcp/utilities/exceptions.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Callable, Iterable, Mapping
2
+ from typing import Any
3
+
4
+ import httpx
5
+ import mcp.types
6
+ from exceptiongroup import BaseExceptionGroup
7
+ from mcp import McpError
8
+
9
+ import fastmcp
10
+
11
+
12
+ def iter_exc(group: BaseExceptionGroup):
13
+ for exc in group.exceptions:
14
+ if isinstance(exc, BaseExceptionGroup):
15
+ yield from iter_exc(exc)
16
+ else:
17
+ yield exc
18
+
19
+
20
+ def _exception_handler(group: BaseExceptionGroup):
21
+ for leaf in iter_exc(group):
22
+ if isinstance(leaf, httpx.ConnectTimeout):
23
+ raise McpError(
24
+ error=mcp.types.ErrorData(
25
+ code=httpx.codes.REQUEST_TIMEOUT,
26
+ message="Timed out while waiting for response.",
27
+ )
28
+ )
29
+ raise leaf
30
+
31
+
32
+ # this catch handler is used to catch taskgroup exception groups and raise the
33
+ # first exception. This allows more sane debugging.
34
+ _catch_handlers: Mapping[
35
+ type[BaseException] | Iterable[type[BaseException]],
36
+ Callable[[BaseExceptionGroup[Any]], Any],
37
+ ] = {
38
+ Exception: _exception_handler,
39
+ }
40
+
41
+
42
+ def get_catch_handlers() -> Mapping[
43
+ type[BaseException] | Iterable[type[BaseException]],
44
+ Callable[[BaseExceptionGroup[Any]], Any],
45
+ ]:
46
+ if fastmcp.settings.settings.client_raise_first_exceptiongroup_error:
47
+ return _catch_handlers
48
+ else:
49
+ return {}
tests/client/test_client.py CHANGED
@@ -1,6 +1,8 @@
 
1
  from typing import cast
2
 
3
  import pytest
 
4
  from pydantic import AnyUrl
5
 
6
  from fastmcp.client import Client
@@ -27,6 +29,12 @@ def fastmcp_server():
27
  """Add two numbers together."""
28
  return a + b
29
 
 
 
 
 
 
 
30
  # Add a resource
31
  @server.resource(uri="data://users")
32
  async def get_users():
@@ -78,8 +86,8 @@ async def test_list_tools(fastmcp_server):
78
  result = await client.list_tools()
79
 
80
  # Check that our tools are available
81
- assert len(result) == 2
82
- assert set(tool.name for tool in result) == {"greet", "add"}
83
 
84
 
85
  async def test_list_tools_mcp(fastmcp_server):
@@ -91,8 +99,8 @@ async def test_list_tools_mcp(fastmcp_server):
91
 
92
  # Check that we got the raw MCP ListToolsResult object
93
  assert hasattr(result, "tools")
94
- assert len(result.tools) == 2
95
- assert set(tool.name for tool in result.tools) == {"greet", "add"}
96
 
97
 
98
  async def test_call_tool(fastmcp_server):
@@ -499,3 +507,39 @@ class TestErrorHandling:
499
  with pytest.raises(Exception) as excinfo:
500
  await client.read_resource(AnyUrl("error://resource/123"))
501
  assert "This is a resource error (xyz)" in str(excinfo.value)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  from typing import cast
3
 
4
  import pytest
5
+ from mcp import McpError
6
  from pydantic import AnyUrl
7
 
8
  from fastmcp.client import Client
 
29
  """Add two numbers together."""
30
  return a + b
31
 
32
+ @server.tool()
33
+ async def sleep(seconds: float) -> str:
34
+ """Sleep for a given number of seconds."""
35
+ await asyncio.sleep(seconds)
36
+ return f"Slept for {seconds} seconds"
37
+
38
  # Add a resource
39
  @server.resource(uri="data://users")
40
  async def get_users():
 
86
  result = await client.list_tools()
87
 
88
  # Check that our tools are available
89
+ assert len(result) == 3
90
+ assert set(tool.name for tool in result) == {"greet", "add", "sleep"}
91
 
92
 
93
  async def test_list_tools_mcp(fastmcp_server):
 
99
 
100
  # Check that we got the raw MCP ListToolsResult object
101
  assert hasattr(result, "tools")
102
+ assert len(result.tools) == 3
103
+ assert set(tool.name for tool in result.tools) == {"greet", "add", "sleep"}
104
 
105
 
106
  async def test_call_tool(fastmcp_server):
 
507
  with pytest.raises(Exception) as excinfo:
508
  await client.read_resource(AnyUrl("error://resource/123"))
509
  assert "This is a resource error (xyz)" in str(excinfo.value)
510
+
511
+
512
+ class TestTimeout:
513
+ async def test_timeout(self, fastmcp_server: FastMCP):
514
+ async with Client(
515
+ transport=FastMCPTransport(fastmcp_server), timeout=0.01
516
+ ) as client:
517
+ with pytest.raises(
518
+ McpError,
519
+ match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds",
520
+ ):
521
+ await client.call_tool("sleep", {"seconds": 0.1})
522
+
523
+ async def test_timeout_tool_call(self, fastmcp_server: FastMCP):
524
+ async with Client(transport=FastMCPTransport(fastmcp_server)) as client:
525
+ with pytest.raises(McpError):
526
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
527
+
528
+ async def test_timeout_tool_call_overrides_client_timeout(
529
+ self, fastmcp_server: FastMCP
530
+ ):
531
+ async with Client(
532
+ transport=FastMCPTransport(fastmcp_server),
533
+ timeout=2,
534
+ ) as client:
535
+ with pytest.raises(McpError):
536
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
537
+
538
+ async def test_timeout_tool_call_overrides_client_timeout_even_if_lower(
539
+ self, fastmcp_server: FastMCP
540
+ ):
541
+ async with Client(
542
+ transport=FastMCPTransport(fastmcp_server),
543
+ timeout=0.01,
544
+ ) as client:
545
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
tests/client/test_sse.py CHANGED
@@ -1,9 +1,11 @@
 
1
  import json
2
  import sys
3
  from collections.abc import Generator
4
 
5
  import pytest
6
  import uvicorn
 
7
  from mcp.types import TextResourceContents
8
  from starlette.applications import Starlette
9
  from starlette.routing import Mount
@@ -31,6 +33,12 @@ def fastmcp_server():
31
  """Add two numbers together."""
32
  return a + b
33
 
 
 
 
 
 
 
34
  # Add a resource
35
  @server.resource(uri="data://users")
36
  async def get_users():
@@ -126,3 +134,53 @@ async def test_nested_sse_server_resolves_correctly():
126
  ) as client:
127
  result = await client.ping()
128
  assert result is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  import json
3
  import sys
4
  from collections.abc import Generator
5
 
6
  import pytest
7
  import uvicorn
8
+ from mcp import McpError
9
  from mcp.types import TextResourceContents
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
 
33
  """Add two numbers together."""
34
  return a + b
35
 
36
+ @server.tool()
37
+ async def sleep(seconds: float) -> str:
38
+ """Sleep for a given number of seconds."""
39
+ await asyncio.sleep(seconds)
40
+ return f"Slept for {seconds} seconds"
41
+
42
  # Add a resource
43
  @server.resource(uri="data://users")
44
  async def get_users():
 
134
  ) as client:
135
  result = await client.ping()
136
  assert result is True
137
+
138
+
139
+ class TestTimeout:
140
+ @pytest.mark.skipif(
141
+ sys.platform == "win32",
142
+ reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
143
+ )
144
+ async def test_timeout(self, sse_server: str):
145
+ with pytest.raises(
146
+ McpError,
147
+ match="Timed out while waiting for response to ClientRequest. Waited 0.01 seconds",
148
+ ):
149
+ async with Client(
150
+ transport=SSETransport(sse_server),
151
+ timeout=0.01,
152
+ ) as client:
153
+ await client.call_tool("sleep", {"seconds": 0.1})
154
+
155
+ async def test_timeout_tool_call(self, sse_server: str):
156
+ async with Client(transport=SSETransport(sse_server)) as client:
157
+ with pytest.raises(McpError, match="Timed out"):
158
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
159
+
160
+ async def test_timeout_tool_call_overrides_client_timeout_if_lower(
161
+ self, sse_server: str
162
+ ):
163
+ async with Client(
164
+ transport=SSETransport(sse_server),
165
+ timeout=2,
166
+ ) as client:
167
+ with pytest.raises(McpError, match="Timed out"):
168
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
169
+
170
+ @pytest.mark.skipif(
171
+ sys.platform == "win32",
172
+ reason="This test is flaky on Windows. Sometimes the client timeout is respected and sometimes it is not.",
173
+ )
174
+ async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower(
175
+ self, sse_server: str
176
+ ):
177
+ """
178
+ With SSE, the tool call timeout always takes precedence over the client.
179
+
180
+ Note: on Windows, the behavior appears unpredictable.
181
+ """
182
+ async with Client(
183
+ transport=SSETransport(sse_server),
184
+ timeout=0.01,
185
+ ) as client:
186
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
tests/client/test_streamable_http.py CHANGED
@@ -1,9 +1,11 @@
 
1
  import json
2
  import sys
3
  from collections.abc import Generator
4
 
5
  import pytest
6
  import uvicorn
 
7
  from mcp.types import TextResourceContents
8
  from starlette.applications import Starlette
9
  from starlette.routing import Mount
@@ -31,6 +33,12 @@ def fastmcp_server():
31
  """Add two numbers together."""
32
  return a + b
33
 
 
 
 
 
 
 
34
  # Add a resource
35
  @server.resource(uri="data://users")
36
  async def get_users():
@@ -139,3 +147,42 @@ async def test_nested_streamable_http_server_resolves_correctly():
139
  ) as client:
140
  result = await client.ping()
141
  assert result is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
  import json
3
  import sys
4
  from collections.abc import Generator
5
 
6
  import pytest
7
  import uvicorn
8
+ from mcp import McpError
9
  from mcp.types import TextResourceContents
10
  from starlette.applications import Starlette
11
  from starlette.routing import Mount
 
33
  """Add two numbers together."""
34
  return a + b
35
 
36
+ @server.tool()
37
+ async def sleep(seconds: float) -> str:
38
+ """Sleep for a given number of seconds."""
39
+ await asyncio.sleep(seconds)
40
+ return f"Slept for {seconds} seconds"
41
+
42
  # Add a resource
43
  @server.resource(uri="data://users")
44
  async def get_users():
 
147
  ) as client:
148
  result = await client.ping()
149
  assert result is True
150
+
151
+
152
+ class TestTimeout:
153
+ async def test_timeout(self, streamable_http_server: str):
154
+ # note this transport behaves differently than others and raises
155
+ # McpError from the *client* context
156
+ with pytest.raises(McpError, match="Timed out"):
157
+ async with Client(
158
+ transport=StreamableHttpTransport(streamable_http_server),
159
+ timeout=0.01,
160
+ ) as client:
161
+ await client.call_tool("sleep", {"seconds": 0.1})
162
+
163
+ async def test_timeout_tool_call(self, streamable_http_server: str):
164
+ async with Client(
165
+ transport=StreamableHttpTransport(streamable_http_server),
166
+ ) as client:
167
+ with pytest.raises(McpError):
168
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
169
+
170
+ async def test_timeout_tool_call_overrides_client_timeout(
171
+ self, streamable_http_server: str
172
+ ):
173
+ async with Client(
174
+ transport=StreamableHttpTransport(streamable_http_server),
175
+ timeout=2,
176
+ ) as client:
177
+ with pytest.raises(McpError):
178
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.01)
179
+
180
+ async def test_timeout_client_timeout_overrides_tool_call_timeout_if_lower(
181
+ self, streamable_http_server: str
182
+ ):
183
+ with pytest.raises(McpError):
184
+ async with Client(
185
+ transport=StreamableHttpTransport(streamable_http_server),
186
+ timeout=0.01,
187
+ ) as client:
188
+ await client.call_tool("sleep", {"seconds": 0.1}, timeout=2)
tests/server/test_openapi.py CHANGED
@@ -15,7 +15,7 @@ from pydantic.networks import AnyUrl
15
 
16
  from fastmcp import FastMCP
17
  from fastmcp.client import Client
18
- from fastmcp.exceptions import ClientError
19
  from fastmcp.server.openapi import (
20
  FastMCPOpenAPI,
21
  OpenAPIResource,
@@ -1029,7 +1029,7 @@ async def test_none_path_parameters_rejected(
1029
  # Create a client and try to call a tool with a None path parameter
1030
  async with Client(mcp_server) as client:
1031
  # get_user has a required path parameter user_id
1032
- with pytest.raises(ClientError, match="Missing required path parameters"):
1033
  await client.call_tool(
1034
  "update_user_name_users__user_id__name_patch",
1035
  {
 
15
 
16
  from fastmcp import FastMCP
17
  from fastmcp.client import Client
18
+ from fastmcp.exceptions import ToolError
19
  from fastmcp.server.openapi import (
20
  FastMCPOpenAPI,
21
  OpenAPIResource,
 
1029
  # Create a client and try to call a tool with a None path parameter
1030
  async with Client(mcp_server) as client:
1031
  # get_user has a required path parameter user_id
1032
+ with pytest.raises(ToolError, match="Missing required path parameters"):
1033
  await client.call_tool(
1034
  "update_user_name_users__user_id__name_patch",
1035
  {
tests/server/test_proxy.py CHANGED
@@ -4,11 +4,12 @@ from typing import Any
4
  import mcp.types
5
  import pytest
6
  from dirty_equals import Contains
 
7
 
8
  from fastmcp import FastMCP
9
  from fastmcp.client import Client
10
  from fastmcp.client.transports import FastMCPTransport
11
- from fastmcp.exceptions import ClientError
12
  from fastmcp.server.proxy import FastMCPProxy
13
 
14
  USERS = [
@@ -109,7 +110,7 @@ class TestTools:
109
  assert proxy_result[0].text == "3"
110
 
111
  async def test_error_tool_raises_error(self, proxy_server):
112
- with pytest.raises(ClientError, match=""):
113
  async with Client(proxy_server) as client:
114
  await client.call_tool("error_tool", {})
115
 
@@ -147,9 +148,7 @@ class TestResources:
147
  assert json.loads(result[0].text) == USERS
148
 
149
  async def test_read_resource_returns_none_if_not_found(self, proxy_server):
150
- with pytest.raises(
151
- ClientError, match="Unknown resource: resource://nonexistent"
152
- ):
153
  async with Client(proxy_server) as client:
154
  await client.read_resource("resource://nonexistent")
155
 
 
4
  import mcp.types
5
  import pytest
6
  from dirty_equals import Contains
7
+ from mcp import McpError
8
 
9
  from fastmcp import FastMCP
10
  from fastmcp.client import Client
11
  from fastmcp.client.transports import FastMCPTransport
12
+ from fastmcp.exceptions import ToolError
13
  from fastmcp.server.proxy import FastMCPProxy
14
 
15
  USERS = [
 
110
  assert proxy_result[0].text == "3"
111
 
112
  async def test_error_tool_raises_error(self, proxy_server):
113
+ with pytest.raises(ToolError, match=""):
114
  async with Client(proxy_server) as client:
115
  await client.call_tool("error_tool", {})
116
 
 
148
  assert json.loads(result[0].text) == USERS
149
 
150
  async def test_read_resource_returns_none_if_not_found(self, proxy_server):
151
+ with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"):
 
 
152
  async with Client(proxy_server) as client:
153
  await client.read_resource("resource://nonexistent")
154
 
tests/server/test_server.py CHANGED
@@ -1,6 +1,7 @@
1
  from typing import Annotated
2
 
3
  import pytest
 
4
  from mcp.types import (
5
  TextContent,
6
  TextResourceContents,
@@ -8,7 +9,7 @@ from mcp.types import (
8
  from pydantic import Field
9
 
10
  from fastmcp import Client, FastMCP
11
- from fastmcp.exceptions import ClientError, NotFoundError
12
 
13
 
14
  class TestCreateServer:
@@ -296,7 +297,7 @@ class TestResourceDecorator:
296
  async def test_no_resources_before_decorator(self):
297
  mcp = FastMCP()
298
 
299
- with pytest.raises(ClientError, match="Unknown resource"):
300
  async with Client(mcp) as client:
301
  await client.read_resource("resource://data")
302
 
 
1
  from typing import Annotated
2
 
3
  import pytest
4
+ from mcp import McpError
5
  from mcp.types import (
6
  TextContent,
7
  TextResourceContents,
 
9
  from pydantic import Field
10
 
11
  from fastmcp import Client, FastMCP
12
+ from fastmcp.exceptions import NotFoundError
13
 
14
 
15
  class TestCreateServer:
 
297
  async def test_no_resources_before_decorator(self):
298
  mcp = FastMCP()
299
 
300
+ with pytest.raises(McpError, match="Unknown resource"):
301
  async with Client(mcp) as client:
302
  await client.read_resource("resource://data")
303
 
tests/server/test_server_interactions.py CHANGED
@@ -8,6 +8,7 @@ from typing import Annotated, Literal
8
 
9
  import pydantic_core
10
  import pytest
 
11
  from mcp.types import (
12
  BlobResourceContents,
13
  ImageContent,
@@ -18,7 +19,7 @@ from pydantic import AnyUrl, Field
18
 
19
  from fastmcp import Client, Context, FastMCP
20
  from fastmcp.client.transports import FastMCPTransport
21
- from fastmcp.exceptions import ClientError
22
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
23
  from fastmcp.resources import FileResource, FunctionResource
24
  from fastmcp.utilities.types import Image
@@ -320,7 +321,7 @@ class TestToolParameters:
320
 
321
  async with Client(mcp) as client:
322
  with pytest.raises(
323
- ClientError,
324
  match="Error calling tool 'my_tool'",
325
  ):
326
  await client.call_tool("my_tool", {"x": "not an int"})
@@ -365,7 +366,7 @@ class TestToolParameters:
365
  pass
366
 
367
  async with Client(mcp) as client:
368
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
369
  await client.call_tool("analyze", {"x": 0})
370
 
371
  async def test_default_field_validation(self):
@@ -376,7 +377,7 @@ class TestToolParameters:
376
  pass
377
 
378
  async with Client(mcp) as client:
379
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
380
  await client.call_tool("analyze", {"x": 0})
381
 
382
  async def test_default_field_is_still_required_if_no_default_specified(self):
@@ -387,7 +388,7 @@ class TestToolParameters:
387
  pass
388
 
389
  async with Client(mcp) as client:
390
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
391
  await client.call_tool("analyze", {})
392
 
393
  async def test_literal_type_validation_error(self):
@@ -398,7 +399,7 @@ class TestToolParameters:
398
  pass
399
 
400
  async with Client(mcp) as client:
401
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
402
  await client.call_tool("analyze", {"x": "c"})
403
 
404
  async def test_literal_type_validation_success(self):
@@ -426,7 +427,7 @@ class TestToolParameters:
426
  return x.value
427
 
428
  async with Client(mcp) as client:
429
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
430
  await client.call_tool("analyze", {"x": "some-color"})
431
 
432
  async def test_enum_type_validation_success(self):
@@ -462,7 +463,7 @@ class TestToolParameters:
462
  assert isinstance(result[0], TextContent)
463
  assert result[0].text == "1.0"
464
 
465
- with pytest.raises(ClientError, match="Error calling tool 'analyze'"):
466
  await client.call_tool("analyze", {"x": "not a number"})
467
 
468
  async def test_path_type(self):
@@ -489,7 +490,7 @@ class TestToolParameters:
489
  return str(path)
490
 
491
  async with Client(mcp) as client:
492
- with pytest.raises(ClientError, match="Error calling tool 'send_path'"):
493
  await client.call_tool("send_path", {"path": 1})
494
 
495
  async def test_uuid_type(self):
@@ -515,7 +516,7 @@ class TestToolParameters:
515
  return str(x)
516
 
517
  async with Client(mcp) as client:
518
- with pytest.raises(ClientError, match="Error calling tool 'send_uuid'"):
519
  await client.call_tool("send_uuid", {"x": "not a uuid"})
520
 
521
  async def test_datetime_type(self):
@@ -554,7 +555,7 @@ class TestToolParameters:
554
  return x.isoformat()
555
 
556
  async with Client(mcp) as client:
557
- with pytest.raises(ClientError, match="Error calling tool 'send_datetime'"):
558
  await client.call_tool("send_datetime", {"x": "not a datetime"})
559
 
560
  async def test_date_type(self):
@@ -1230,7 +1231,7 @@ class TestPrompts:
1230
  async def test_get_unknown_prompt(self):
1231
  """Test error when getting unknown prompt."""
1232
  mcp = FastMCP()
1233
- with pytest.raises(ClientError, match="Unknown prompt"):
1234
  async with Client(mcp) as client:
1235
  await client.get_prompt("unknown")
1236
 
@@ -1242,7 +1243,7 @@ class TestPrompts:
1242
  def prompt_fn(name: str) -> str:
1243
  return f"Hello, {name}!"
1244
 
1245
- with pytest.raises(ClientError, match="Missing required arguments"):
1246
  async with Client(mcp) as client:
1247
  await client.get_prompt("prompt_fn")
1248
 
 
8
 
9
  import pydantic_core
10
  import pytest
11
+ from mcp import McpError
12
  from mcp.types import (
13
  BlobResourceContents,
14
  ImageContent,
 
19
 
20
  from fastmcp import Client, Context, FastMCP
21
  from fastmcp.client.transports import FastMCPTransport
22
+ from fastmcp.exceptions import ToolError
23
  from fastmcp.prompts.prompt import EmbeddedResource, PromptMessage
24
  from fastmcp.resources import FileResource, FunctionResource
25
  from fastmcp.utilities.types import Image
 
321
 
322
  async with Client(mcp) as client:
323
  with pytest.raises(
324
+ ToolError,
325
  match="Error calling tool 'my_tool'",
326
  ):
327
  await client.call_tool("my_tool", {"x": "not an int"})
 
366
  pass
367
 
368
  async with Client(mcp) as client:
369
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
370
  await client.call_tool("analyze", {"x": 0})
371
 
372
  async def test_default_field_validation(self):
 
377
  pass
378
 
379
  async with Client(mcp) as client:
380
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
381
  await client.call_tool("analyze", {"x": 0})
382
 
383
  async def test_default_field_is_still_required_if_no_default_specified(self):
 
388
  pass
389
 
390
  async with Client(mcp) as client:
391
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
392
  await client.call_tool("analyze", {})
393
 
394
  async def test_literal_type_validation_error(self):
 
399
  pass
400
 
401
  async with Client(mcp) as client:
402
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
403
  await client.call_tool("analyze", {"x": "c"})
404
 
405
  async def test_literal_type_validation_success(self):
 
427
  return x.value
428
 
429
  async with Client(mcp) as client:
430
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
431
  await client.call_tool("analyze", {"x": "some-color"})
432
 
433
  async def test_enum_type_validation_success(self):
 
463
  assert isinstance(result[0], TextContent)
464
  assert result[0].text == "1.0"
465
 
466
+ with pytest.raises(ToolError, match="Error calling tool 'analyze'"):
467
  await client.call_tool("analyze", {"x": "not a number"})
468
 
469
  async def test_path_type(self):
 
490
  return str(path)
491
 
492
  async with Client(mcp) as client:
493
+ with pytest.raises(ToolError, match="Error calling tool 'send_path'"):
494
  await client.call_tool("send_path", {"path": 1})
495
 
496
  async def test_uuid_type(self):
 
516
  return str(x)
517
 
518
  async with Client(mcp) as client:
519
+ with pytest.raises(ToolError, match="Error calling tool 'send_uuid'"):
520
  await client.call_tool("send_uuid", {"x": "not a uuid"})
521
 
522
  async def test_datetime_type(self):
 
555
  return x.isoformat()
556
 
557
  async with Client(mcp) as client:
558
+ with pytest.raises(ToolError, match="Error calling tool 'send_datetime'"):
559
  await client.call_tool("send_datetime", {"x": "not a datetime"})
560
 
561
  async def test_date_type(self):
 
1231
  async def test_get_unknown_prompt(self):
1232
  """Test error when getting unknown prompt."""
1233
  mcp = FastMCP()
1234
+ with pytest.raises(McpError, match="Unknown prompt"):
1235
  async with Client(mcp) as client:
1236
  await client.get_prompt("unknown")
1237
 
 
1243
  def prompt_fn(name: str) -> str:
1244
  return f"Hello, {name}!"
1245
 
1246
+ with pytest.raises(McpError, match="Missing required arguments"):
1247
  async with Client(mcp) as client:
1248
  await client.get_prompt("prompt_fn")
1249
 
tests/tools/test_tool.py CHANGED
@@ -4,7 +4,7 @@ from pydantic import BaseModel
4
 
5
  from fastmcp import FastMCP, Image
6
  from fastmcp.client import Client
7
- from fastmcp.exceptions import ClientError
8
  from fastmcp.tools.tool import Tool
9
  from fastmcp.utilities.tests import temporary_settings
10
 
@@ -299,7 +299,7 @@ class TestLegacyToolJsonParsing:
299
 
300
  async with Client(mcp) as client:
301
  with pytest.raises(
302
- ClientError,
303
  match="Error calling tool 'process_list'",
304
  ):
305
  await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
 
4
 
5
  from fastmcp import FastMCP, Image
6
  from fastmcp.client import Client
7
+ from fastmcp.exceptions import ToolError
8
  from fastmcp.tools.tool import Tool
9
  from fastmcp.utilities.tests import temporary_settings
10
 
 
299
 
300
  async with Client(mcp) as client:
301
  with pytest.raises(
302
+ ToolError,
303
  match="Error calling tool 'process_list'",
304
  ):
305
  await client.call_tool("process_list", {"items": "['a', 'b', 3]"})
uv.lock CHANGED
@@ -340,7 +340,7 @@ dev = [
340
  requires-dist = [
341
  { name = "exceptiongroup", specifier = ">=1.2.2" },
342
  { name = "httpx", specifier = ">=0.28.1" },
343
- { name = "mcp", specifier = ">=1.8.0,<2.0.0" },
344
  { name = "openapi-pydantic", specifier = ">=0.5.1" },
345
  { name = "python-dotenv", specifier = ">=1.1.0" },
346
  { name = "rich", specifier = ">=13.9.4" },
@@ -573,7 +573,7 @@ wheels = [
573
 
574
  [[package]]
575
  name = "mcp"
576
- version = "1.8.0"
577
  source = { registry = "https://pypi.org/simple" }
578
  dependencies = [
579
  { name = "anyio" },
@@ -586,9 +586,9 @@ dependencies = [
586
  { name = "starlette" },
587
  { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
588
  ]
589
- 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" }
590
  wheels = [
591
- { 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" },
592
  ]
593
 
594
  [[package]]
 
340
  requires-dist = [
341
  { name = "exceptiongroup", specifier = ">=1.2.2" },
342
  { name = "httpx", specifier = ">=0.28.1" },
343
+ { name = "mcp", specifier = ">=1.9.0,<2.0.0" },
344
  { name = "openapi-pydantic", specifier = ">=0.5.1" },
345
  { name = "python-dotenv", specifier = ">=1.1.0" },
346
  { name = "rich", specifier = ">=13.9.4" },
 
573
 
574
  [[package]]
575
  name = "mcp"
576
+ version = "1.9.0"
577
  source = { registry = "https://pypi.org/simple" }
578
  dependencies = [
579
  { name = "anyio" },
 
586
  { name = "starlette" },
587
  { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
588
  ]
589
+ sdist = { url = "https://files.pythonhosted.org/packages/bc/8d/0f4468582e9e97b0a24604b585c651dfd2144300ecffd1c06a680f5c8861/mcp-1.9.0.tar.gz", hash = "sha256:905d8d208baf7e3e71d70c82803b89112e321581bcd2530f9de0fe4103d28749", size = 281432 }
590
  wheels = [
591
+ { url = "https://files.pythonhosted.org/packages/a5/d5/22e36c95c83c80eb47c83f231095419cf57cf5cca5416f1c960032074c78/mcp-1.9.0-py3-none-any.whl", hash = "sha256:9dfb89c8c56f742da10a5910a1f64b0d2ac2c3ed2bd572ddb1cfab7f35957178", size = 125082 },
592
  ]
593
 
594
  [[package]]