Spaces:
Running
Running
Merge branch 'main' into sse-bugfix
Browse files- .github/workflows/run-static.yml +3 -6
- .github/workflows/run-tests.yml +5 -17
- docs/clients/client.mdx +50 -8
- docs/clients/transports.mdx +166 -104
- docs/patterns/testing.mdx +1 -1
- docs/servers/proxy.mdx +6 -3
- examples/in_memory_proxy_example.py +89 -0
- pyproject.toml +2 -1
- src/fastmcp/cli/cli.py +1 -1
- src/fastmcp/client/client.py +88 -22
- src/fastmcp/client/transports.py +53 -34
- src/fastmcp/exceptions.py +2 -0
- src/fastmcp/server/context.py +6 -3
- src/fastmcp/server/http.py +22 -6
- src/fastmcp/server/server.py +19 -18
- src/fastmcp/settings.py +44 -28
- src/fastmcp/utilities/exceptions.py +49 -0
- tests/client/test_client.py +48 -4
- tests/client/test_sse.py +58 -0
- tests/client/test_streamable_http.py +47 -0
- tests/server/test_openapi.py +2 -2
- tests/server/test_proxy.py +4 -5
- tests/server/test_server.py +3 -2
- tests/server/test_server_interactions.py +14 -13
- tests/tools/test_tool.py +2 -2
- uv.lock +4 -4
.github/workflows/run-static.yml
CHANGED
|
@@ -14,13 +14,10 @@ on:
|
|
| 14 |
- "uv.lock"
|
| 15 |
- "pyproject.toml"
|
| 16 |
- ".github/workflows/**"
|
|
|
|
|
|
|
| 17 |
pull_request:
|
| 18 |
-
|
| 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
|
|
|
|
| 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 |
-
*
|
| 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 |
-
|
| 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 |
-
|
| 51 |
client_ws = Client(ws_url)
|
| 52 |
client_stdio = Client(server_script)
|
| 53 |
|
| 54 |
print(client_in_memory.transport)
|
| 55 |
-
print(
|
| 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 |
-
# <
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
|
|
|
| 39 |
|
| 40 |
-
#
|
| 41 |
-
|
| 42 |
-
transport_explicit = StreamableHttpTransport(url=http_url, headers=headers)
|
| 43 |
-
client_explicit = Client(transport_explicit)
|
| 44 |
|
| 45 |
-
async def
|
| 46 |
async with client:
|
| 47 |
tools = await client.list_tools()
|
| 48 |
-
print(f"
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
```
|
| 53 |
|
| 54 |
### SSE (Server-Sent Events)
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
```python
|
| 63 |
from fastmcp import Client
|
| 64 |
from fastmcp.client.transports import SSETransport
|
|
|
|
| 65 |
|
| 66 |
-
|
|
|
|
| 67 |
|
| 68 |
-
#
|
| 69 |
-
|
| 70 |
-
transport_explicit = SSETransport(url=sse_url)
|
| 71 |
-
client_explicit = Client(transport_explicit)
|
| 72 |
|
| 73 |
-
async def
|
| 74 |
async with client:
|
| 75 |
tools = await client.list_tools()
|
| 76 |
-
print(f"
|
| 77 |
|
| 78 |
-
|
| 79 |
```
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
| 87 |
-
*
|
| 88 |
-
*
|
| 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" #
|
| 97 |
|
| 98 |
# Option 1: Inferred transport
|
| 99 |
-
|
| 100 |
|
| 101 |
-
# Option 2: Explicit transport
|
| 102 |
-
|
| 103 |
script_path=server_script,
|
| 104 |
-
python_cmd="/usr/bin/python3.11", #
|
| 105 |
-
# args=["--some-server-arg"],
|
| 106 |
-
# env={"MY_VAR": "value"},
|
| 107 |
-
# cwd="/path/to/run/in" # Set working directory
|
| 108 |
)
|
| 109 |
-
|
| 110 |
|
| 111 |
-
async def
|
| 112 |
async with client:
|
| 113 |
tools = await client.list_tools()
|
| 114 |
print(f"Connected via Python Stdio, found tools: {tools}")
|
| 115 |
|
| 116 |
-
|
| 117 |
-
# asyncio.run(use_stdio_client(client_explicit))
|
| 118 |
```
|
| 119 |
|
| 120 |
<Warning>
|
| 121 |
-
The server script
|
| 122 |
</Warning>
|
| 123 |
|
| 124 |
### Node.js Stdio
|
| 125 |
|
| 126 |
-
*
|
| 127 |
-
*
|
| 128 |
-
*
|
| 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" #
|
| 137 |
|
| 138 |
# Option 1: Inferred transport
|
| 139 |
-
|
| 140 |
|
| 141 |
# Option 2: Explicit transport
|
| 142 |
-
|
| 143 |
script_path=node_server_script,
|
| 144 |
-
node_cmd="node" #
|
| 145 |
)
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
-
|
| 149 |
-
# async with client_explicit:
|
| 150 |
-
# tools = await client_explicit.list_tools()
|
| 151 |
```
|
| 152 |
|
| 153 |
### UVX Stdio (Experimental)
|
| 154 |
|
| 155 |
-
*
|
| 156 |
-
*
|
| 157 |
-
*
|
| 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 |
-
#
|
| 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", #
|
| 169 |
-
# with_packages=["boto3", "requests"]
|
| 170 |
-
# tool_args=["--config", "prod.yaml"] # Pass args to the tool itself
|
| 171 |
)
|
| 172 |
client = Client(transport)
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
```
|
| 177 |
|
| 178 |
### NPX Stdio (Experimental)
|
| 179 |
|
| 180 |
-
*
|
| 181 |
-
*
|
| 182 |
-
*
|
| 183 |
|
| 184 |
Similar to `UvxStdioTransport`, but for the Node.js ecosystem.
|
| 185 |
|
| 186 |
```python
|
|
|
|
| 187 |
from fastmcp.client.transports import NpxStdioTransport
|
| 188 |
|
| 189 |
-
#
|
| 190 |
transport = NpxStdioTransport(
|
| 191 |
-
package="
|
| 192 |
-
# args=["--port", "stdio"] #
|
| 193 |
)
|
| 194 |
client = Client(transport)
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
```
|
| 199 |
|
| 200 |
## In-Memory Transports
|
| 201 |
|
| 202 |
### FastMCP Transport
|
| 203 |
|
| 204 |
-
*
|
| 205 |
-
*
|
| 206 |
-
*
|
| 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 |
-
|
| 215 |
|
| 216 |
# 1. Create your FastMCP server instance
|
| 217 |
server = FastMCP(name="InMemoryServer")
|
|
|
|
| 218 |
@server.tool()
|
| 219 |
-
def ping():
|
|
|
|
| 220 |
|
| 221 |
# 2. Create a client pointing directly to the server instance
|
| 222 |
-
#
|
| 223 |
-
client_inferred = Client(server)
|
| 224 |
-
|
| 225 |
-
# Option B: Explicit
|
| 226 |
-
transport_explicit = FastMCPTransport(mcp=server)
|
| 227 |
-
client_explicit = Client(transport_explicit)
|
| 228 |
|
| 229 |
-
|
| 230 |
-
async
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
print(f"In-memory call result: {result[0].text}") # Output: pong
|
| 234 |
|
| 235 |
-
|
| 236 |
```
|
| 237 |
-
Communication happens through efficient in-memory queues, making it very fast.
|
| 238 |
-
|
| 239 |
-
## Choosing a Transport
|
| 240 |
|
| 241 |
-
|
| 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!"
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 102 |
proxy = FastMCP.from_client(
|
| 103 |
-
|
| 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.
|
| 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
|
| 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
|
| 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
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 49 |
):
|
| 50 |
self.transport = infer_transport(transport)
|
| 51 |
self._session: ClientSession | None = None
|
| 52 |
-
self.
|
| 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":
|
| 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 |
-
#
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 105 |
-
|
| 106 |
-
self.
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 422 |
RuntimeError: If called while the client is not connected.
|
| 423 |
"""
|
| 424 |
-
result = await self.call_tool_mcp(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
if result.isError:
|
| 426 |
msg = cast(mcp.types.TextContent, result.content[0]).text
|
| 427 |
-
raise
|
| 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
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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[
|
|
|
|
|
|
|
| 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
|
| 193 |
raise ValueError(f"Unknown transport: {transport}")
|
| 194 |
|
| 195 |
if transport == "stdio":
|
| 196 |
await self.run_stdio_async(**transport_kwargs)
|
| 197 |
-
elif transport
|
| 198 |
-
await self.run_http_async(transport=
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 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:
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 87 |
-
|
| 88 |
-
|
| 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) ==
|
| 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) ==
|
| 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
|
| 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(
|
| 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
|
| 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(
|
| 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
|
| 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(
|
| 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
|
| 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 |
-
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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(
|
| 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
|
| 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 |
-
|
| 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.
|
| 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.
|
| 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/
|
| 590 |
wheels = [
|
| 591 |
-
{ url = "https://files.pythonhosted.org/packages/
|
| 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]]
|