Merge remote-tracking branch 'origin/main' into feature/client-auto-default

# Conflicts:
#	tests/client/test_streamable_http.py
#	tests/server/middleware/test_initialization_middleware.py
#	tests/server/tasks/test_task_status_notifications.py
This commit is contained in:
Jeremiah Lowin 2026-07-20 12:02:38 -04:00
commit 1a788cf349
No known key found for this signature in database
76 changed files with 2238 additions and 608 deletions

View file

@ -40,7 +40,7 @@ inputs:
model:
description: "Model to use for Claude"
required: false
default: "claude-opus-4-6"
default: "claude-opus-4-8"
allowed-bots:
description: "Allowed bot usernames, or '*' for all bots"

View file

@ -19,7 +19,7 @@ runs:
MAX_PROCS="2"
EXTRA_FLAGS=""
elif [ "${{ inputs.test-type }}" == "client_process" ]; then
MARKER="client_process"
MARKER="client_process or subprocess_heavy"
TIMEOUT="5"
MAX_PROCS="0"
EXTRA_FLAGS="-x"
@ -29,14 +29,20 @@ runs:
MAX_PROCS="0"
EXTRA_FLAGS="-x"
else
MARKER="not integration and not client_process and not conformance"
MARKER="not integration and not client_process and not subprocess_heavy and not conformance"
TIMEOUT="5"
MAX_PROCS="4"
EXTRA_FLAGS=""
fi
# Windows previously ran serially: parallel workers crashed intermittently
# when many tests spawned stdio subprocesses (#2715, reverted in #2726).
# Most of those tests now run in-memory, but tests that spawn a fresh
# interpreter importing all of FastMCP still crash xdist workers on the
# 2-core Windows runners. They carry the subprocess_heavy marker and run
# in the serial client_process step instead.
PARALLEL_FLAGS=""
if [ "$MAX_PROCS" != "0" ] && [ "${{ runner.os }}" != "Windows" ]; then
if [ "$MAX_PROCS" != "0" ]; then
PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal"
fi

View file

@ -103,7 +103,7 @@ jobs:
--allowedTools "Bash(gh issue view:*)","Bash(gh search:*)","Bash(gh issue list:*)","Bash(gh api:*)","Bash(gh issue comment:*)",Task
settings: |
{
"model": "claude-sonnet-4-6",
"model": "claude-sonnet-5",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}

View file

@ -153,10 +153,10 @@ jobs:
allowed_non_write_users: "*"
allowed_bots: "marvin-context-protocol"
claude_args: |
--allowedTools "Bash(gh label list:*)","Bash(bash .github/scripts/triage-label.sh:*)",mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request_files
--allowedTools "Bash(gh label list:*)","Bash(bash .github/scripts/triage-label.sh:*)",mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files
settings: |
{
"model": "claude-sonnet-4-6",
"model": "claude-sonnet-5",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}",
"TRIAGE_REPO": "${{ github.repository }}",
@ -164,11 +164,16 @@ jobs:
}
}
# Triage is fire-and-forget: nobody watches a green run, so a blocked tool
# call has to fail the job or it goes unnoticed indefinitely. A too-narrow
# allowlist silently produced zero labels across a dozen PRs before anyone
# spotted it, because the run still reported success.
- name: Fail if any tool call was denied
# Triage is fire-and-forget: nobody watches a green run, so a broken
# allowlist has to fail the job or it goes unnoticed indefinitely — a
# mangled pattern silently produced zero labels across a dozen PRs
# because the run still reported success.
#
# Only denials of commands we MEANT to grant indicate that breakage. An
# agent reaching for something never on the allowlist (falling back to
# `gh issue view` when the API is down, say) is behaving normally, and
# failing on that would cry wolf during every GitHub incident.
- name: Fail if an allowlisted tool was denied
if: always() && steps.marvin.conclusion != 'skipped'
env:
EXECUTION_FILE: ${{ steps.marvin.outputs.execution_file }}
@ -190,24 +195,32 @@ jobs:
# Anchor to result entries rather than recursing with `..`, which
# descends into each denial's `tool_input` and double-counts any
# denied command that happens to mention the field name.
if ! denials=$(jq -s '
if ! summary=$(jq -sr '
[ .[] | if type == "array" then .[] else . end ]
| map(select(type == "object" and .type == "result"))
| map(
[ (.permission_denials | if type == "array" then length else 0 end),
(.permission_denials_count | if type == "number" then . else 0 end) ]
| max
)
| add // 0
| map(.permission_denials // []) | flatten
| map(.tool_input.command // "")
| { total: length,
granted: map(select(
startswith("gh label list")
or startswith("bash .github/scripts/triage-label.sh")
))
}
| "\(.total)\t\(.granted | length)\t\(.granted | join(" | "))"
' "$file"); then
echo "::error::Could not parse Marvin execution log ($file)."
exit 1
fi
echo "Permission denials: $denials"
if [[ "$denials" -gt 0 ]]; then
echo "::error::Marvin was denied $denials tool call(s), so triage likely applied no labels. Check the --allowedTools allowlist in this workflow: any Bash(...) pattern containing a space must be individually quoted, or it gets torn apart by whitespace splitting before it reaches the permission matcher."
IFS=$'\t' read -r total granted commands <<<"$summary"
echo "Denied tool calls: $total (of which allowlisted: $granted)"
if [[ "$granted" -gt 0 ]]; then
echo "::error::Marvin was denied $granted call(s) to tools this workflow grants, so it could not apply labels: ${commands}. The --allowedTools value is not reaching the permission matcher intact — claude_args is lexed with shell-quote, so any Bash(...) pattern containing a space must be quoted or it is split into fragments."
exit 1
fi
if [[ "$total" -gt 0 ]]; then
echo "::notice::Marvin was denied $total call(s), none of them to tools this workflow grants. That is expected when it probes for a tool we deliberately withhold; the allowlist is intact."
fi
- name: Upload Marvin execution log
if: always() && steps.marvin.conclusion != 'skipped'

View file

@ -48,7 +48,7 @@ jobs:
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
- name: Run serial subprocess tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process
@ -69,7 +69,7 @@ jobs:
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
- name: Run serial subprocess tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process

View file

@ -67,7 +67,7 @@ jobs:
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
- name: Run serial subprocess tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process

View file

@ -37,7 +37,7 @@ async with Client(
"https://your-server.fastmcp.app/mcp",
auth="<your-token>",
) as client:
await client.ping()
await client.list_tools()
```
You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`:
@ -52,7 +52,7 @@ transport = StreamableHttpTransport(
)
async with Client(transport) as client:
await client.ping()
await client.list_tools()
```
## `BearerAuth` Helper
@ -67,7 +67,7 @@ async with Client(
"https://your-server.fastmcp.app/mcp",
auth=BearerAuth(token="<your-token>"),
) as client:
await client.ping()
await client.list_tools()
```
## Custom Headers
@ -84,5 +84,5 @@ async with Client(
headers={"X-API-Key": "<your-token>"},
),
) as client:
await client.ping()
await client.list_tools()
```

View file

@ -32,7 +32,7 @@ async with Client(
client_metadata_url="https://myapp.example.com/oauth/client.json",
),
) as client:
await client.ping()
await client.list_tools()
```
When the server supports CIMD, the client uses your metadata URL as its `client_id` instead of performing Dynamic Client Registration. The server fetches your document, validates it, and proceeds with the standard OAuth authorization flow.

View file

@ -29,7 +29,7 @@ from fastmcp import Client
# Uses default OAuth settings
async with Client("https://your-server.fastmcp.app/mcp", auth="oauth") as client:
await client.ping()
await client.list_tools()
```
@ -44,7 +44,7 @@ from fastmcp.client.auth import OAuth
oauth = OAuth(scopes=["user"])
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
await client.ping()
await client.list_tools()
```
<Note>
@ -125,7 +125,7 @@ encrypted_storage = FernetEncryptionWrapper(
oauth = OAuth(token_storage=encrypted_storage)
async with Client("https://your-server.fastmcp.app/mcp", auth=oauth) as client:
await client.ping()
await client.list_tools()
```
You can use any `AsyncKeyValue`-compatible backend from the [key-value library](https://github.com/strawgate/py-key-value) including Redis, DynamoDB, and more. Wrap your storage in `FernetEncryptionWrapper` for encryption.
@ -150,7 +150,7 @@ async with Client(
client_metadata_url="https://myapp.example.com/oauth/client.json",
),
) as client:
await client.ping()
await client.list_tools()
```
See the [CIMD Authentication](/clients/auth/cimd) page for complete documentation on creating, hosting, and validating CIMD documents.
@ -172,7 +172,7 @@ async with Client(
client_secret="my-client-secret",
),
) as client:
await client.ping()
await client.list_tools()
```
Public clients that rely on PKCE for security can omit `client_secret`:

View file

@ -37,9 +37,6 @@ client = Client("my_mcp_server.py")
async def main():
async with client:
# Basic server interaction
await client.ping()
# List available operations
tools = await client.list_tools()
resources = await client.list_resources()
@ -186,6 +183,16 @@ Set `mode="legacy"` to force the initialize handshake. This behaves identically
client = Client("https://example.com/mcp", mode="legacy")
```
Legacy mode is also what you need for the capabilities that depend on a live session between client and server. The handshake opens a persistent back-channel the server can push requests down, and the modern era removed it. Pin `mode="legacy"` when your code relies on any of these:
- **[Sampling](/clients/sampling)** — server-initiated LLM completion requests
- **[Roots](/clients/roots)** — server-initiated requests for the client's roots
- **[Elicitation](/clients/elicitation)** — server-initiated requests for user input, which modern connections replace with [input-required rounds](/clients/elicitation#input-required-rounds)
- **[Background tasks](/clients/tasks)** — submitting an operation with `task=True`
- `client.ping()` and `transport.get_session_id()`
A FastMCP server serves both eras, so a default client negotiates the modern one and these raise an era-specific error. Pinning the handshake restores them.
You can also pin a specific modern protocol version to adopt it directly, without a discovery probe:
```python
@ -328,6 +335,8 @@ See [Prompts](/clients/prompts) for detailed documentation including argument se
The client supports callback handlers for advanced server interactions. These let you respond to server-initiated requests and receive notifications.
Sampling, elicitation, and roots are all server-initiated, so they belong to the handshake era described under [protocol negotiation](#protocol-negotiation). A default client negotiates the newest era both peers share, where the server has no back-channel to push those requests down, so an example that exercises them pins `mode="legacy"`. Logging and progress arrive as notifications on the response stream and work in either era.
```python
from fastmcp import Client
from fastmcp.client.logging import LogMessage
@ -344,6 +353,7 @@ async def sampling_handler(messages, params, context):
client = Client(
"my_mcp_server.py",
mode="legacy",
log_handler=log_handler,
progress_handler=progress_handler,
sampling_handler=sampling_handler,

View file

@ -13,6 +13,10 @@ Use this when you need to respond to server requests for user input during tool
Elicitation allows MCP servers to request structured input from users during operations. Instead of requiring all inputs upfront, servers can interactively ask for missing parameters, request clarification, or gather additional context.
Two routes reach that outcome, and the protocol era the client negotiates decides which one applies. Handshake-era connections let the server push an elicitation request down the back-channel the handshake opens, which is the flow the next few sections describe. Modern-era connections work through [input-required rounds](#input-required-rounds) instead, where the server returns a description of what it needs and the client answers with a fresh call. You write the same `elicitation_handler` either way — FastMCP routes it to whichever mechanism the connection supports.
A client left at its default `mode="auto"` negotiates the newest era it shares with the server, so the examples below pin `mode="legacy"` to exercise the server-initiated flow. See [protocol negotiation](/clients/client#protocol-negotiation) for the full picture.
## Handler Template
```python
@ -54,6 +58,7 @@ async def elicitation_handler(
client = Client(
"my_mcp_server.py",
mode="legacy",
elicitation_handler=elicitation_handler,
)
```
@ -138,6 +143,7 @@ async def elicitation_handler(message, response_type, params, context):
client = Client(
"my_mcp_server.py",
mode="legacy",
elicitation_handler=elicitation_handler
)
```

View file

@ -13,6 +13,8 @@ Use this when you need to tell servers what local resources the client has acces
Roots inform servers about resources the client can provide. Servers can use this information to adjust behavior or provide more relevant responses.
A server reads roots by asking the client for them, which requires the back-channel that only the handshake era of the MCP protocol provides. A client left at its default `mode="auto"` negotiates the newest era it shares with the server, so against a FastMCP server it lands on the modern era and the server's request for roots fails. Both examples below pin `mode="legacy"` for that reason. See [protocol negotiation](/clients/client#protocol-negotiation) for the full picture.
## Static Roots
Provide a list of roots when creating the client:
@ -22,6 +24,7 @@ from fastmcp import Client
client = Client(
"my_mcp_server.py",
mode="legacy",
roots=["/path/to/root1", "/path/to/root2"]
)
```
@ -40,6 +43,7 @@ async def roots_callback(context: RequestContext) -> list[str]:
client = Client(
"my_mcp_server.py",
mode="legacy",
roots=roots_callback
)
```

View file

@ -13,6 +13,8 @@ Use this when you need to respond to server requests for LLM completions.
MCP servers can request LLM completions from clients during tool execution. This enables servers to delegate AI reasoning to the client, which controls which LLM is used and how requests are made.
Sampling belongs to the handshake era of the MCP protocol, which is the only era that gives a server a back-channel to push requests down. A client left at its default `mode="auto"` negotiates the newest era it shares with the server, so against a FastMCP server it lands on the modern era and sampling requests fail. Every example on this page pins `mode="legacy"` for that reason. See [protocol negotiation](/clients/client#protocol-negotiation) for the full picture.
## Handler Template
```python
@ -49,6 +51,7 @@ async def sampling_handler(
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=sampling_handler,
)
```
@ -109,6 +112,7 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=OpenAISamplingHandler(default_model="gpt-4o"),
)
```
@ -120,6 +124,7 @@ from openai import AsyncOpenAI
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=OpenAISamplingHandler(
default_model="llama-3.1-70b",
client=AsyncOpenAI(base_url="http://localhost:8000/v1"),
@ -128,7 +133,7 @@ client = Client(
```
<Note>
Install the OpenAI handler with `pip install fastmcp[openai]`.
Install the OpenAI handler with `pip install 'fastmcp[openai]'`.
</Note>
### Anthropic Handler
@ -141,12 +146,13 @@ from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=AnthropicSamplingHandler(default_model="claude-sonnet-4-5"),
)
```
<Note>
Install the Anthropic handler with `pip install fastmcp[anthropic]`.
Install the Anthropic handler with `pip install 'fastmcp[anthropic]'`.
</Note>
### Google Gemini Handler
@ -159,12 +165,13 @@ from fastmcp.client.sampling.handlers.google_genai import GoogleGenaiSamplingHan
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=GoogleGenaiSamplingHandler(default_model="gemini-2.0-flash"),
)
```
<Note>
Install the Google Gemini handler with `pip install fastmcp[gemini]`.
Install the Google Gemini handler with `pip install 'fastmcp[gemini]'`.
</Note>
## Sampling Capabilities
@ -176,6 +183,7 @@ from fastmcp.types import SamplingCapability
client = Client(
"my_mcp_server.py",
mode="legacy",
sampling_handler=basic_handler,
sampling_capabilities=SamplingCapability(), # No tool support
)

View file

@ -14,6 +14,8 @@ Use this when you need to run long operations asynchronously while doing other w
The MCP task protocol lets you request operations to run in the background. The call returns a Task object immediately, letting you track progress, cancel operations, or await results.
FastMCP submits a task over the session that the `initialize` handshake opens, which makes background execution a handshake-era capability. A client left at its default `mode="auto"` negotiates the newest era it shares with the server, so against a FastMCP server it lands on the modern era where task submission is unavailable. The examples on this page pin `mode="legacy"` for that reason. See [protocol negotiation](/clients/client#protocol-negotiation) for the full picture.
## Requesting Background Execution
Pass `task=True` to run an operation as a background task:
@ -21,7 +23,7 @@ Pass `task=True` to run an operation as a background task:
```python
from fastmcp import Client
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
# Start a background task
task = await client.call_tool("slow_computation", {"duration": 10}, task=True)
@ -154,7 +156,7 @@ import asyncio
from fastmcp import Client
async def main():
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
# Start background task
task = await client.call_tool(
"slow_computation",

View file

@ -86,7 +86,7 @@ client = Client(transport)
async def efficient_multiple_operations():
async with client:
await client.ping()
await client.list_tools()
async with client: # Reuses the same subprocess
await client.call_tool("process_data", {"file": "data.csv"})

View file

@ -299,22 +299,19 @@ async def test_database_tool():
### Testing Network Transports
While in-memory testing covers most unit testing needs, you'll occasionally need to test actual network transports like HTTP or SSE. FastMCP provides two approaches: in-process async servers (preferred), and separate subprocess servers (for special cases).
In-memory testing covers most unit testing needs, but some behavior only exists over HTTP: middleware, authentication, session management, header handling, and SSE streaming. To test those, serve your server over HTTP with `asgi_client`.
#### In-Process Network Testing (Preferred)
#### Testing Over HTTP
<VersionBadge version="2.13.0" />
<VersionBadge version="3.5.0" />
For most network transport tests, use `run_server_async` as an async context manager. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support:
`asgi_client` builds your server's real Starlette app, starts its lifespan, and hands you a connected `Client` that talks to it over the full HTTP stack. The one thing it skips is the socket: requests are dispatched straight into the ASGI application on the current event loop, so there is no port to bind, no uvicorn to start, and no connection to negotiate. Everything else — middleware, authentication, session management, SSE framing — runs exactly as it does in production.
```python
import pytest
from fastmcp import FastMCP, Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
from fastmcp import FastMCP
from fastmcp.utilities.tests import asgi_client
def create_test_server() -> FastMCP:
"""Create a test server instance."""
server = FastMCP("TestServer")
@server.tool
@ -323,26 +320,89 @@ def create_test_server() -> FastMCP:
return server
@pytest.fixture
async def http_server() -> str:
"""Start server in-process for testing."""
server = create_test_server()
async with run_server_async(server) as url:
yield url
async def test_http_transport(http_server: str):
"""Test actual HTTP transport behavior."""
async with Client(
transport=StreamableHttpTransport(http_server)
) as client:
result = await client.ping()
assert result is True
async def test_greet_over_http():
async with asgi_client(create_test_server()) as client:
greeting = await client.call_tool("greet", {"name": "World"})
assert greeting.data == "Hello, World!"
```
The `run_server_async` context manager automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages.
Pass `transport="sse"` to exercise the SSE app instead of streamable HTTP, `path=` to serve on a custom path, `headers=` and `auth=` to configure the client's requests, and any other keyword argument to configure the `Client` itself.
```python
async def test_tenant_header_is_visible_to_tools():
async with asgi_client(
create_test_server(),
headers={"X-Tenant-ID": "acme"},
timeout=5,
) as client:
await client.list_tools()
```
#### Sharing One Server Across Tests
When several tests share a server but each needs its own client, use `asgi_server` in a fixture. It yields an `ASGIServer`, whose `client()` method produces a fresh client — with its own session — on demand.
```python
import pytest
from fastmcp import FastMCP
from fastmcp.utilities.tests import ASGIServer, asgi_server
@pytest.fixture
async def http_server():
server = FastMCP("TestServer")
@server.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
async with asgi_server(server) as running_server:
yield running_server
async def test_greet(http_server: ASGIServer):
async with http_server.client() as client:
greeting = await client.call_tool("greet", {"name": "World"})
assert greeting.data == "Hello, World!"
async def test_sessions_are_isolated(http_server: ASGIServer):
async with (
http_server.client(mode="legacy") as first,
http_server.client(mode="legacy") as second,
):
assert await first.ping() is True
assert await second.ping() is True
```
Sessions belong to the handshake era of the MCP protocol, and so does `ping`, so a test that is about session behavior pins `mode="legacy"`. Every keyword argument `client()` doesn't consume itself is passed straight to `Client`. See [protocol negotiation](/clients/client#protocol-negotiation).
For assertions about raw HTTP — status codes, response headers, metadata endpoints — `http_client()` returns an `httpx.AsyncClient` bound to the same app. Because nothing is listening on the network, this is the only way to make raw requests; a plain `httpx.AsyncClient()` cannot reach the server.
```python
async def test_unauthenticated_request_is_rejected(http_server: ASGIServer):
async with http_server.http_client() as http:
response = await http.post(http_server.url, json={"jsonrpc": "2.0", "id": 1})
assert response.status_code in (400, 401)
```
If you need to build the client transport yourself, `transport()` returns a `StreamableHttpTransport` or `SSETransport` already wired to the in-process app.
#### Testing on a Real Port
<VersionBadge version="2.13.0" />
`run_server_async` starts a real uvicorn server on a real TCP port as a task in the current process and yields its URL. Reach for it only when the subject of the test is the network itself — real sockets, TLS, or a server that must be reachable by something other than an in-process client.
```python
from fastmcp import FastMCP, Client
from fastmcp.utilities.tests import run_server_async
async def test_server_binds_a_real_port():
server = FastMCP("TestServer")
async with run_server_async(server) as url:
assert url.startswith("http://127.0.0.1:")
async with Client(url) as client:
assert await client.list_tools() == []
```
#### Subprocess Testing (Special Cases)
@ -375,8 +435,8 @@ async def test_http_transport(http_server: str):
async with Client(
transport=StreamableHttpTransport(http_server)
) as client:
result = await client.ping()
assert result is True
tools = await client.list_tools()
assert "greet" in [tool.name for tool in tools]
```
The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. Use this only when subprocess isolation is truly necessary, as it's slower and harder to debug than in-process testing. FastMCP uses the `client_process` marker to isolate these tests in CI.

View file

@ -81,7 +81,8 @@ auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"})
async def main():
async with Client("http://127.0.0.1:8000/mcp", auth=auth) as client:
assert await client.ping()
tools = await client.list_tools()
print(f"Authenticated. Server exposes {len(tools)} tools.")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -116,7 +116,8 @@ import asyncio
async def main():
async with Client("http://localhost:8000/mcp", auth="oauth") as client:
assert await client.ping()
tools = await client.list_tools()
print(f"Authenticated. Server exposes {len(tools)} tools.")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -101,7 +101,8 @@ import asyncio
async def main():
async with Client("http://localhost:8000/mcp", auth="oauth") as client:
assert await client.ping()
tools = await client.list_tools()
print(f"Authenticated. Server exposes {len(tools)} tools.")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -137,7 +137,7 @@ Client support for sampling is optional—some clients may not implement it. To
FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format.
<Note>
Install handlers with `pip install fastmcp[openai]` or `pip install fastmcp[anthropic]`.
Install handlers with `pip install 'fastmcp[openai]'` or `pip install 'fastmcp[anthropic]'`.
</Note>
```python

View file

@ -212,7 +212,7 @@ client = Client(
```
<Note>
Install the OpenAI handler with `pip install fastmcp[openai]`.
Install the OpenAI handler with `pip install 'fastmcp[openai]'`.
</Note>
### Anthropic Handler
@ -246,7 +246,7 @@ client = Client(
```
<Note>
Install the Anthropic handler with `pip install fastmcp[anthropic]`.
Install the Anthropic handler with `pip install 'fastmcp[anthropic]'`.
</Note>
### Tool Execution

View file

@ -446,7 +446,7 @@ Client support for sampling is optional—some clients may not implement it. To
FastMCP provides built-in handlers for [OpenAI and Anthropic APIs](/v2/clients/sampling#built-in-handlers). These handlers support the full sampling API including tools, automatically converting your Python functions to each provider's format.
<Note>
Install handlers with `pip install fastmcp[openai]` or `pip install fastmcp[anthropic]`.
Install handlers with `pip install 'fastmcp[openai]'` or `pip install 'fastmcp[anthropic]'`.
</Note>
```python

View file

@ -5,7 +5,7 @@ These examples demonstrate FastMCP's sampling API, which allows server tools to
## Prerequisites
```bash
pip install fastmcp[anthropic]
pip install 'fastmcp[anthropic]'
export ANTHROPIC_API_KEY=your-key
```
@ -59,4 +59,4 @@ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
handler = OpenAISamplingHandler(default_model="gpt-4o-mini")
```
And install with `pip install fastmcp[openai]`.
And install with `pip install 'fastmcp[openai]'`.

View file

@ -1052,7 +1052,7 @@ def _build_picker_html(tools: list[dict[str, Any]]) -> str:
from prefab_ui.components.form import Form
from prefab_ui.rx import RESULT, Rx
except ImportError:
return "<html><body><p>prefab-ui not installed. Run: pip install fastmcp[apps]</p></body></html>"
return "<html><body><p>prefab-ui not installed. Run: pip install 'fastmcp[apps]'</p></body></html>"
if not tools:
with Column(gap=4, css_class="p-6 max-w-2xl mx-auto") as view:

View file

@ -8,7 +8,7 @@ import secrets
import ssl
import uuid
import weakref
from collections.abc import Callable, Coroutine, Mapping, Sequence
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping, Sequence
from contextlib import AsyncExitStack, asynccontextmanager, suppress
from dataclasses import dataclass, field
from pathlib import Path
@ -43,8 +43,9 @@ from mcp_types import (
TaskStatusNotification,
TaskStatusNotificationParams,
)
from mcp_types.methods import validate_server_result
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
from pydantic import AnyUrl
from pydantic import AnyUrl, ValidationError
import fastmcp as fastmcp
from fastmcp.client.auth.oauth import OAuth
@ -154,6 +155,68 @@ def _synthesize_discover(protocol_version: str) -> mcp_types.DiscoverResult:
)
@asynccontextmanager
async def _conformant_discover_only(
session: ClientSession,
) -> AsyncIterator[None]:
"""Hold ``session.send_discover`` to the same wire schema every later reply must meet.
``negotiate_auto`` accepts a probe that parses as the version-free
``DiscoverResult``, whose ``resultType``/``ttlMs``/``cacheScope`` all carry
SDK-side defaults. Every request *after* adoption is instead checked against
the strict per-version surface (``validate_server_result``), where those same
three fields are required. A server that answers ``server/discover`` without
them therefore passes the probe and then fails every subsequent call the
connection is adopted into an era the peer cannot actually serve.
Closing that gap means judging the probe by the rule that will govern the rest
of the connection. A result that would be rejected later is not positive
evidence of a modern peer, so it is reported as an ordinary probe failure and
``negotiate_auto`` falls back to the initialize handshake, exactly as it does
for a server with no ``server/discover`` at all.
"""
send_discover = session.send_discover
async def _checked_send_discover(version: str) -> dict[str, Any]:
raw = await send_discover(version)
try:
validate_server_result("server/discover", version, raw)
except ValidationError as e:
# Ordered before the ValueError arm below: pydantic's ValidationError
# subclasses ValueError, so a broader clause first would swallow it.
logger.debug(
"server/discover at %s is not %s-conformant (%s); "
"falling back to the initialize handshake",
version,
version,
e,
)
raise MCPError(
code=mcp_types.INVALID_PARAMS,
message=(
f"server/discover result is not conformant with {version}; "
"treating the server as handshake-era"
),
) from e
except (KeyError, ValueError):
# No schema on file for this method/version pair, so there is nothing to
# judge the probe against; leave the verdict to negotiate_auto's parse.
return raw
return raw
# A transport may itself have installed a `send_discover` override, so restore
# whatever was there rather than assuming the class attribute.
had_own = "send_discover" in vars(session)
session.send_discover = _checked_send_discover # ty: ignore[invalid-assignment]
try:
yield
finally:
if had_own:
session.send_discover = send_discover # ty: ignore[invalid-assignment]
else:
del session.send_discover
@dataclass
class _FoldedExtensions:
"""`Client(extensions=...)` folded into the shapes `ClientSession` consumes.
@ -871,7 +934,8 @@ class Client(
await self.session.initialize()
)
elif effective_mode == "auto":
await negotiate_auto(self.session)
async with _conformant_discover_only(self.session):
await negotiate_auto(self.session)
# auto may have fallen back to the legacy handshake; surface its
# InitializeResult through the existing public property when so.
self._session_state.initialize_result = (

View file

@ -41,7 +41,7 @@ try:
except ImportError as e:
raise ImportError(
"The `anthropic` package is not installed. "
"Install it with `pip install fastmcp-slim[anthropic]` or add `anthropic` to your dependencies."
"Install it with `pip install 'fastmcp-slim[anthropic]'` or add `anthropic` to your dependencies."
) from e
__all__ = ["AnthropicSamplingHandler"]

View file

@ -28,7 +28,7 @@ try:
except ImportError as e:
raise ImportError(
"The `google-genai` package is not installed. "
"Install it with `pip install fastmcp-slim[gemini]` or add `google-genai` "
"Install it with `pip install 'fastmcp-slim[gemini]'` or add `google-genai` "
"to your dependencies."
) from e

View file

@ -190,7 +190,7 @@ class AggregateProvider(Provider):
async def _list_tools(self) -> Sequence[Tool]:
"""List all tools from all providers."""
results = await gather(
*[p.list_tools() for p in self.providers],
(p.list_tools() for p in self.providers),
return_exceptions=True,
)
return self._collect_list_results(results, "list_tools")
@ -200,7 +200,7 @@ class AggregateProvider(Provider):
) -> Tool | None:
"""Get tool by name from providers."""
results = await gather(
*[p.get_tool(name, version) for p in self.providers],
(p.get_tool(name, version) for p in self.providers),
return_exceptions=True,
)
return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type]
@ -208,7 +208,7 @@ class AggregateProvider(Provider):
async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None:
"""Query all child providers for an app tool."""
results = await gather(
*[p.get_app_tool(app_name, tool_name) for p in self.providers],
(p.get_app_tool(app_name, tool_name) for p in self.providers),
return_exceptions=True,
)
for r in results:
@ -223,7 +223,7 @@ class AggregateProvider(Provider):
async def get_tool_by_hash(self, tool_hash: str, tool_name: str) -> Tool | None:
"""Query all child providers for a tool matching a hash."""
results = await gather(
*[p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers],
(p.get_tool_by_hash(tool_hash, tool_name) for p in self.providers),
return_exceptions=True,
)
for r in results:
@ -242,7 +242,7 @@ class AggregateProvider(Provider):
async def _list_resources(self) -> Sequence[Resource]:
"""List all resources from all providers."""
results = await gather(
*[p.list_resources() for p in self.providers],
(p.list_resources() for p in self.providers),
return_exceptions=True,
)
return self._collect_list_results(results, "list_resources")
@ -252,7 +252,7 @@ class AggregateProvider(Provider):
) -> Resource | None:
"""Get resource by URI from providers."""
results = await gather(
*[p.get_resource(uri, version) for p in self.providers],
(p.get_resource(uri, version) for p in self.providers),
return_exceptions=True,
)
return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type]
@ -264,7 +264,7 @@ class AggregateProvider(Provider):
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
"""List all resource templates from all providers."""
results = await gather(
*[p.list_resource_templates() for p in self.providers],
(p.list_resource_templates() for p in self.providers),
return_exceptions=True,
)
return self._collect_list_results(results, "list_resource_templates")
@ -274,7 +274,7 @@ class AggregateProvider(Provider):
) -> ResourceTemplate | None:
"""Get resource template by URI from providers."""
results = await gather(
*[p.get_resource_template(uri, version) for p in self.providers],
(p.get_resource_template(uri, version) for p in self.providers),
return_exceptions=True,
)
return self._get_highest_version_result(
@ -288,7 +288,7 @@ class AggregateProvider(Provider):
async def _list_prompts(self) -> Sequence[Prompt]:
"""List all prompts from all providers."""
results = await gather(
*[p.list_prompts() for p in self.providers],
(p.list_prompts() for p in self.providers),
return_exceptions=True,
)
return self._collect_list_results(results, "list_prompts")
@ -298,7 +298,7 @@ class AggregateProvider(Provider):
) -> Prompt | None:
"""Get prompt by name from providers."""
results = await gather(
*[p.get_prompt(name, version) for p in self.providers],
(p.get_prompt(name, version) for p in self.providers),
return_exceptions=True,
)
return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type]
@ -310,7 +310,7 @@ class AggregateProvider(Provider):
async def get_tasks(self) -> Sequence[FastMCPComponent]:
"""Get all task-eligible components from all providers."""
results = await gather(
*[p.get_tasks() for p in self.providers],
(p.get_tasks() for p in self.providers),
return_exceptions=True,
)
return self._collect_list_results(results, "get_tasks")

View file

@ -496,12 +496,19 @@ class Provider:
Used by the server during startup to register functions with Docket.
"""
# Fetch all component types in parallel
# Fetch all component types in parallel. Iterate the bound methods
# rather than a tuple of already-called coroutines: a parenthesized
# comma expression is a tuple, so it would create all four coroutines
# before `gather` starts, which is exactly what `gather` asks callers
# to avoid.
results = await gather(
self._list_tools(),
self._list_resources(),
self._list_resource_templates(),
self._list_prompts(),
fetch()
for fetch in (
self._list_tools,
self._list_resources,
self._list_resource_templates,
self._list_prompts,
)
)
tools = cast("Sequence[Tool]", results[0])
resources = cast("Sequence[Resource]", results[1])

View file

@ -390,7 +390,7 @@ async def execute_tools(
# Execute in parallel
if tool_concurrency == 0:
# Unlimited parallel execution
return await gather(*[_execute_single_tool(tc) for tc in tool_calls])
return await gather(_execute_single_tool(tc) for tc in tool_calls)
else:
# Bounded parallel execution with semaphore
semaphore = anyio.Semaphore(tool_concurrency)
@ -399,7 +399,7 @@ async def execute_tools(
async with semaphore:
return await _execute_single_tool(tool_use)
return await gather(*[bounded_execute(tc) for tc in tool_calls])
return await gather(bounded_execute(tc) for tc in tool_calls)
# --- Helper functions for sampling ---

View file

@ -707,7 +707,8 @@ class TransformedTool(Tool):
schema = {
"type": "object",
"properties": new_props,
"required": list(new_required),
# Iterate props (not the set) for deterministic ordering
"required": [p for p in new_props if p in new_required],
"additionalProperties": False,
}
@ -899,7 +900,11 @@ class TransformedTool(Tool):
result = {
"type": "object",
"properties": merged_props,
"required": list(final_required),
# Iterate props (not the set) for deterministic ordering; keep any
# required names not present in properties (sorted) rather than
# silently dropping them.
"required": [p for p in merged_props if p in final_required]
+ sorted(final_required - set(merged_props)),
"additionalProperties": False,
}

View file

@ -0,0 +1,322 @@
"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx.
Ported from the MCP Python SDK's test suite (`tests/interaction/transports/_bridge.py`,
MIT licensed).
`httpx2.ASGITransport` runs the application to completion and only then hands the buffered
response to the caller, so a server that streams its response as the streamable HTTP
transport's SSE responses do — can never converse with the client mid-request: a
server-initiated request nested inside a still-open call deadlocks.
`StreamingASGITransport` removes that limitation by running the application as a background
task and forwarding every `http.response.body` chunk to the client the moment it is sent.
Everything happens on the one event loop: no sockets, no threads, no sleeps.
The behavioural contract:
- The request body is buffered before the application is invoked (MCP requests are small
JSON documents); the response streams chunk by chunk.
- Closing the response or the whole client delivers `http.disconnect` to the
application, exactly as a real server sees when its peer goes away.
- An exception the application raises before sending `http.response.start` fails the
originating request with that same exception. After the response has started, a failure
is visible to the client only through the response itself (status code, truncated body)
the same signal a real server over a real socket would give.
The transport owns an anyio task group for the application tasks; it is opened and closed by
`httpx2.AsyncClient`'s own context manager, so the client must be used as a context manager.
Closing the transport cancels every running application task by default; set
`cancel_on_close=False` to wait for the application's own disconnect handling instead, which
is what the legacy SSE transport relies on for resource cleanup.
"""
from __future__ import annotations
import asyncio
import math
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from types import TracebackType
import anyio
import anyio.abc
import httpx2
from anyio.streams.memory import MemoryObjectReceiveStream
from starlette.types import ASGIApp, Message, Scope
class _StreamingResponseBody(httpx2.AsyncByteStream):
"""A response body that yields chunks as the application produces them.
Closing it tells the application the client has gone away (`http.disconnect`),
mirroring a peer that drops the connection mid-response.
"""
def __init__(
self,
chunks: MemoryObjectReceiveStream[bytes],
client_disconnected: anyio.Event,
) -> None:
self._chunks = chunks
self._client_disconnected = client_disconnected
async def __aiter__(self) -> AsyncIterator[bytes]:
async for chunk in self._chunks:
yield chunk
async def aclose(self) -> None:
self._client_disconnected.set()
await self._chunks.aclose()
class StreamingASGITransport(httpx2.AsyncBaseTransport):
"""Drive an ASGI application in-process, streaming each response as it is produced.
This is an `httpx2` transport, so it plugs into anything that accepts an
`httpx2.AsyncClient` including FastMCP's client transports via their
`httpx_client_factory` argument.
Args:
app: The ASGI application to drive (e.g. `FastMCP.http_app()`).
cancel_on_close: When True (the default), closing the transport cancels every
application task still running, so harness teardown can never hang. Set to
False to wait for the application's own disconnect handling to complete
instead, which the legacy SSE server transport relies on for cleanup.
Example:
Drive a FastMCP server's real HTTP app with no sockets:
```python
import httpx2
from fastmcp import FastMCP
from fastmcp.utilities.asgi_transport import StreamingASGITransport
mcp = FastMCP("test")
app = mcp.http_app(transport="http")
async with app.router.lifespan_context(app):
transport = StreamingASGITransport(app)
async with httpx2.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
response = await client.get("/mcp")
```
"""
_task_group: anyio.abc.TaskGroup
def __init__(self, app: ASGIApp, *, cancel_on_close: bool = True) -> None:
self._app = app
self._cancel_on_close = cancel_on_close
async def __aenter__(self) -> StreamingASGITransport:
self._task_group = anyio.create_task_group()
await self._task_group.__aenter__()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None = None,
exc_value: BaseException | None = None,
traceback: TracebackType | None = None,
) -> None:
# httpx closes every streamed response before closing the transport, so by now each
# application task has been delivered `http.disconnect`. Either cancel immediately,
# or wait for the application's own disconnect handling to unwind.
if self._cancel_on_close:
self._task_group.cancel_scope.cancel()
await self._task_group.__aexit__(exc_type, exc_value, traceback)
async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response:
if not isinstance(request.stream, httpx2.AsyncByteStream):
raise TypeError(
"StreamingASGITransport requires an async request stream; "
f"got {type(request.stream).__name__}."
)
request_body = b"".join([chunk async for chunk in request.stream])
scope: Scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": request.method,
"scheme": request.url.scheme,
"path": request.url.path,
"raw_path": request.url.raw_path.split(b"?", maxsplit=1)[0],
"query_string": request.url.query,
"root_path": "",
"headers": [(name.lower(), value) for name, value in request.headers.raw],
"server": (request.url.host, request.url.port),
"client": ("127.0.0.1", 1234),
}
request_delivered = False
start_received = False
client_disconnected = anyio.Event()
response_started = anyio.Event()
response_status = 0
response_headers: list[tuple[bytes, bytes]] = []
application_error: Exception | None = None
chunk_writer, chunk_reader = anyio.create_memory_object_stream[bytes](math.inf)
async def receive_request() -> Message:
nonlocal request_delivered
if not request_delivered:
request_delivered = True
return {
"type": "http.request",
"body": request_body,
"more_body": False,
}
await client_disconnected.wait()
return {"type": "http.disconnect"}
async def send_response(message: Message) -> None:
nonlocal response_status, response_headers, start_received
if message["type"] == "http.response.start":
start_received = True
response_status = message["status"]
response_headers = list(message.get("headers", []))
response_started.set()
return
if message["type"] != "http.response.body":
raise RuntimeError(f"Unexpected ASGI message type: {message['type']}")
body: bytes = message.get("body", b"")
if body:
await chunk_writer.send(body)
if not message.get("more_body", False):
await chunk_writer.aclose()
async def run_application() -> None:
nonlocal application_error
try:
await self._app(scope, receive_request, send_response)
except Exception as exc:
# The bridge is the application's outermost boundary: a crash must fail the
# originating request (or show up in the already-started response), never
# tear down the task group shared with every other in-flight request.
application_error = exc
finally:
response_started.set()
await chunk_writer.aclose()
self._task_group.start_soon(run_application)
try:
await response_started.wait()
# Only a failure *before* the start message can fail the request. Once the
# response has started the client sees the failure as a truncated body, which
# is the same signal a real server over a real socket would give.
if application_error is not None and not start_received:
raise application_error
except BaseException:
# No response will be built, so close the reader the response body would have
# owned and tell the application its peer has gone away.
client_disconnected.set()
await chunk_reader.aclose()
raise
return httpx2.Response(
status_code=response_status,
headers=response_headers,
stream=_StreamingResponseBody(chunk_reader, client_disconnected),
request=request,
)
@asynccontextmanager
async def run_asgi_lifespan(app: ASGIApp) -> AsyncIterator[None]:
"""Run an ASGI application's lifespan, driving the protocol as a real server does.
The application's lifespan runs inside a dedicated task for the whole duration of
the context. This matters because a lifespan typically owns cancel scopes and task
groups anyio requires those to be exited by the task that entered them, which
rules out entering the lifespan on one task and leaving it on another (as a pytest
fixture's setup and teardown phases may do).
Args:
app: The ASGI application whose lifespan should run.
Raises:
RuntimeError: If the application reports `lifespan.startup.failed`, or reports
`lifespan.shutdown.failed` (or crashes during shutdown) while the context
body itself completed successfully. A failure inside the body takes
precedence and propagates unchanged.
"""
receive_queue: asyncio.Queue[Message] = asyncio.Queue()
startup_complete: asyncio.Future[None] = asyncio.get_running_loop().create_future()
shutdown_complete: asyncio.Future[None] = asyncio.get_running_loop().create_future()
async def receive() -> Message:
return await receive_queue.get()
async def send(message: Message) -> None:
if message["type"] == "lifespan.startup.complete":
if not startup_complete.done():
startup_complete.set_result(None)
elif message["type"] == "lifespan.startup.failed":
if not startup_complete.done():
startup_complete.set_exception(
RuntimeError(
f"ASGI application startup failed: {message.get('message', '')}"
)
)
elif message["type"] == "lifespan.shutdown.complete":
if not shutdown_complete.done():
shutdown_complete.set_result(None)
elif message["type"] == "lifespan.shutdown.failed":
if not shutdown_complete.done():
shutdown_complete.set_exception(
RuntimeError(
"ASGI application shutdown failed: "
f"{message.get('message', '')}"
)
)
async def run_lifespan() -> None:
scope: Scope = {"type": "lifespan", "asgi": {"version": "3.0"}}
try:
await app(scope, receive, send)
except BaseException as exc:
# The app died without completing the handshake; surface that to whichever
# side is still waiting rather than hanging.
if not startup_complete.done():
startup_complete.set_exception(exc)
if not shutdown_complete.done():
shutdown_complete.set_exception(exc)
raise
else:
if not startup_complete.done():
startup_complete.set_exception(
RuntimeError("ASGI application exited before completing startup")
)
if not shutdown_complete.done():
shutdown_complete.set_result(None)
task = asyncio.create_task(run_lifespan())
await receive_queue.put({"type": "lifespan.startup"})
try:
await startup_complete
except BaseException:
task.cancel()
with anyio.CancelScope(shield=True):
await asyncio.gather(task, return_exceptions=True)
raise
body_failed = False
try:
yield
except BaseException:
body_failed = True
raise
finally:
await receive_queue.put({"type": "lifespan.shutdown"})
with anyio.CancelScope(shield=True):
results = await asyncio.gather(
shutdown_complete, task, return_exceptions=True
)
# A harness must surface a broken teardown rather than swallow it — but never at
# the cost of masking the failure the body already raised, which is the one the
# caller actually needs to see.
if not body_failed:
for result in results:
if isinstance(result, BaseException) and not isinstance(
result, asyncio.CancelledError
):
raise result

View file

@ -2,7 +2,7 @@
import functools
import inspect
from collections.abc import Awaitable, Callable
from collections.abc import Awaitable, Callable, Iterable
from typing import Any, Literal, TypeVar, overload
import anyio
@ -36,35 +36,55 @@ async def call_sync_fn_in_threadpool(
@overload
async def gather(
*awaitables: Awaitable[T],
awaitables: Iterable[Awaitable[T]],
*,
return_exceptions: Literal[True],
) -> list[T | BaseException]: ...
@overload
async def gather(
*awaitables: Awaitable[T],
awaitables: Iterable[Awaitable[T]],
*,
return_exceptions: Literal[False] = ...,
) -> list[T]: ...
async def gather(
*awaitables: Awaitable[T],
awaitables: Iterable[Awaitable[T]],
*,
return_exceptions: bool = False,
) -> list[T] | list[T | BaseException]:
"""Run awaitables concurrently and return results in order.
Uses anyio TaskGroup for structured concurrency.
``awaitables`` is consumed lazily, one item at a time, right before each
is handed to the task group. Callers with a dynamic number of awaitables
should pass a generator expression (e.g. ``gather(f(x) for x in xs)``)
rather than a list or list comprehension: a list comprehension calls
every ``f(x)`` up front, creating a batch of coroutine objects before
this function even starts, whereas a generator expression creates each
coroutine only as this function's own scheduling loop asks for it. That
matters because coroutine creation and scheduling can be interrupted
between any two bytecode instructions by a synchronous signal handler
(for example pytest-timeout's SIGALRM-based per-test timeout). If that
happens while a whole batch of coroutines is sitting unscheduled, they
are silently abandoned and eventually trigger a "coroutine was never
awaited" warning attributed to whatever unrelated code happens to be
running when the garbage collector gets to them. Lazy consumption keeps
the window in which a created-but-unscheduled coroutine can exist as
small as possible.
Args:
*awaitables: Awaitables to run concurrently
awaitables: Iterable of awaitables to run concurrently.
return_exceptions: If True, exceptions are returned in results.
If False, first exception cancels all and raises.
Returns:
List of results in the same order as input awaitables.
"""
results: list[T | BaseException] = [None] * len(awaitables) # type: ignore[assignment] # ty:ignore[invalid-assignment]
results: list[T | BaseException] = []
async def run_at(i: int, aw: Awaitable[T]) -> None:
try:
@ -75,8 +95,26 @@ async def gather(
else:
raise
pending = enumerate(awaitables)
async with anyio.create_task_group() as tg:
for i, aw in enumerate(awaitables):
tg.start_soon(run_at, i, aw)
for i, aw in pending:
results.append(None) # type: ignore[arg-type] # ty:ignore[invalid-argument-type]
try:
tg.start_soon(run_at, i, aw)
except BaseException:
# `aw` was just created (possibly moments ago, by the
# generator's own iteration) but never handed off - close it
# explicitly so it isn't silently garbage collected later.
if inspect.iscoroutine(aw):
aw.close()
# Lazy consumption keeps the leak window small, but a caller
# that passed an already-built sequence has coroutines sitting
# behind this one that were never scheduled either. Draining
# the iterator closes them too, so `gather` cannot leak
# regardless of how eagerly its argument was constructed.
for _, remaining in pending:
if inspect.iscoroutine(remaining):
remaining.close()
raise
return results

View file

@ -1,11 +1,13 @@
from __future__ import annotations
import asyncio
import copy
import multiprocessing
import socket
import time
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import asynccontextmanager, contextmanager, suppress
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import parse_qs, urlparse
@ -15,9 +17,18 @@ from mcp.shared.auth import AuthorizationCodeResult
from fastmcp import settings
from fastmcp.client.auth.oauth import OAuth
from fastmcp.client.client import Client
from fastmcp.client.transports.http import StreamableHttpTransport
from fastmcp.client.transports.sse import SSETransport
from fastmcp.utilities.asgi_transport import (
StreamingASGITransport,
run_asgi_lifespan,
)
from fastmcp.utilities.http import find_available_port
if TYPE_CHECKING:
from starlette.types import ASGIApp
from fastmcp.server.server import FastMCP
@ -140,6 +151,26 @@ def run_server_in_process(
raise RuntimeError("Server process failed to terminate even after kill")
async def _wait_for_port(host: str, port: int, timeout: float = 5.0) -> None:
"""Poll until a TCP connection to `host:port` is accepted, or raise on timeout."""
deadline = time.monotonic() + timeout
while True:
try:
_, writer = await asyncio.open_connection(host, port)
except (ConnectionRefusedError, OSError):
if time.monotonic() >= deadline:
raise RuntimeError(
f"Server did not start listening on {host}:{port} "
f"within {timeout} seconds"
) from None
await asyncio.sleep(0.001)
else:
writer.close()
with suppress(ConnectionResetError, BrokenPipeError):
await writer.wait_closed()
return
@asynccontextmanager
async def run_server_async(
server: FastMCP,
@ -149,11 +180,13 @@ async def run_server_async(
host: str = "127.0.0.1",
) -> AsyncGenerator[str, None]:
"""
Start a FastMCP server as an asyncio task for in-process async testing.
Start a FastMCP server on a real port as an asyncio task.
This is the recommended way to test FastMCP servers. It runs the server
as an async task in the same process, eliminating subprocess coordination,
sleeps, and cleanup issues.
This runs a real uvicorn server in the current process, bound to a real TCP port,
and yields its URL. Use it when the behaviour under test is genuinely about the
network real sockets, TLS, or a server that must be reachable by something other
than an in-process client. Otherwise prefer `asgi_client` or `asgi_server`, which
exercise the same HTTP stack without binding a port.
Args:
server: FastMCP server instance
@ -189,14 +222,9 @@ async def run_server_async(
assert result.content[0].text == "Hello, World!"
```
"""
import asyncio
if port is None:
port = find_available_port()
# Wait a tiny bit for the port to be released if it was just used
await asyncio.sleep(0.01)
# Start server as a background task
server_task = asyncio.create_task(
server.run_http_async(
@ -211,8 +239,9 @@ async def run_server_async(
# Wait for server lifespan to be ready
await server._started.wait()
# Give uvicorn a moment to bind the port after lifespan is ready
await asyncio.sleep(0.1)
# The lifespan completing does not guarantee uvicorn has bound the port yet, so
# poll until the socket accepts a connection rather than guessing at a sleep.
await _wait_for_port(host, port)
try:
yield f"http://{host}:{port}{path}"
@ -223,6 +252,215 @@ async def run_server_async(
await asyncio.wait_for(server_task, timeout=2.0)
@dataclass(frozen=True)
class ASGIServer:
"""A FastMCP server's real HTTP app, reachable in-process with no sockets.
Yielded by `asgi_server`. The `url` looks like an ordinary server URL and the app
behind it is the genuine article auth middleware, session manager, SSE framing and
redirects all run but every request is dispatched straight into the ASGI
application on the current event loop.
Because nothing is listening on the network, a plain `httpx2.AsyncClient()` cannot
reach this server. Use `client()` for a FastMCP client, `http_client()` for raw HTTP
assertions, and `transport()` when you need to build the client transport yourself.
"""
url: str
app: ASGIApp
transport_type: Literal["http", "streamable-http", "sse"]
def http_client(
self,
headers: dict[str, str] | None = None,
timeout: httpx2.Timeout | None = None,
auth: httpx2.Auth | None = None,
**kwargs: Any,
) -> httpx2.AsyncClient:
"""An `httpx2.AsyncClient` bound to the in-process app, for raw HTTP assertions.
Relative URLs resolve against the server's base URL, and absolute URLs on the
same origin work too, so `client.get(f"{server.url}/health")` reads the same as
it would against a real server.
The signature matches `McpHttpClientFactory`, so this method can also be handed
to anything that takes an `httpx_client_factory`.
"""
# The legacy SSE transport runs the whole MCP session inside its GET request and
# only releases its streams once that request observes a disconnect, so the
# bridge must let the application drain rather than cancelling at close.
cancel_on_close = self.transport_type != "sse"
return httpx2.AsyncClient(
transport=StreamingASGITransport(self.app, cancel_on_close=cancel_on_close),
base_url=self.url,
headers=headers,
timeout=timeout,
auth=auth,
**kwargs,
)
def transport(self, **kwargs: Any) -> StreamableHttpTransport | SSETransport:
"""A FastMCP client transport wired to the in-process app.
Accepts the same keyword arguments as the underlying transport (`headers`,
`auth`, ...); `httpx_client_factory` is supplied automatically.
"""
kwargs.setdefault("httpx_client_factory", self.http_client)
if self.transport_type == "sse":
return SSETransport(self.url, **kwargs)
return StreamableHttpTransport(self.url, **kwargs)
def client(
self,
*,
headers: dict[str, str] | None = None,
auth: httpx2.Auth | Literal["oauth"] | str | None = None,
**client_kwargs: Any,
) -> Client:
"""An unconnected FastMCP `Client` pointed at the in-process app.
`headers` and `auth` configure the underlying HTTP transport; every other
keyword argument is passed to `Client` (`timeout`, `elicitation_handler`, ...).
Use it as a context manager, exactly like any other client.
Args:
headers: HTTP headers to send with every request.
auth: Client authentication, as accepted by the HTTP transports.
**client_kwargs: Additional arguments forwarded to `Client`.
"""
return Client(self.transport(headers=headers, auth=auth), **client_kwargs)
@asynccontextmanager
async def asgi_server(
server: FastMCP,
transport: Literal["http", "streamable-http", "sse"] = "http",
path: str | None = None,
**http_app_kwargs: Any,
) -> AsyncGenerator[ASGIServer, None]:
"""
Serve a FastMCP server's HTTP app in-process, with no socket and no uvicorn.
This is the fastest way to test a FastMCP server over HTTP. The server's real
Starlette app is built with `http_app()` and its lifespan is started, then every
request is dispatched directly into the app on the current event loop. That skips
port binding, uvicorn startup and connection setup entirely, while still exercising
the full HTTP stack: middleware, authentication, session management and SSE
streaming all run exactly as they do in production.
Use this as a fixture when several tests share one server but each needs its own
client. For a single test, `asgi_client` hands you a connected client in one step.
Args:
server: FastMCP server instance.
transport: Transport type ("http", "streamable-http", or "sse").
path: URL path for the server (defaults to "/mcp", or "/sse" for SSE).
**http_app_kwargs: Additional arguments forwarded to `server.http_app()`.
Yields:
An `ASGIServer` describing how to reach the app.
Example:
```python
import pytest
from fastmcp import FastMCP
from fastmcp.utilities.tests import ASGIServer, asgi_server
mcp = FastMCP("test")
@mcp.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
@pytest.fixture
async def server():
async with asgi_server(mcp) as running_server:
yield running_server
async def test_greet(server: ASGIServer):
async with server.client() as client:
result = await client.call_tool("greet", {"name": "World"})
assert result.data == "Hello, World!"
async def test_greet_with_headers(server: ASGIServer):
async with server.client(headers={"X-Tenant": "acme"}) as client:
result = await client.call_tool("greet", {"name": "World"})
assert result.data == "Hello, World!"
```
"""
if path is None:
path = "/sse" if transport == "sse" else "/mcp"
app = server.http_app(transport=transport, path=path, **http_app_kwargs)
# Nothing listens on this origin; it exists so that URLs are well-formed and so
# that host-header checks see a loopback address, as they would locally.
base_url = "http://127.0.0.1"
async with run_asgi_lifespan(app):
yield ASGIServer(
url=f"{base_url}{path}",
app=app,
transport_type=transport,
)
@asynccontextmanager
async def asgi_client(
server: FastMCP,
transport: Literal["http", "streamable-http", "sse"] = "http",
path: str | None = None,
*,
headers: dict[str, str] | None = None,
auth: httpx2.Auth | Literal["oauth"] | str | None = None,
**client_kwargs: Any,
) -> AsyncGenerator[Client, None]:
"""
Serve a FastMCP server over HTTP in-process and yield a connected `Client`.
This is the shortest path to testing a server over a real HTTP stack. The server's
Starlette app is built and started, and requests are dispatched straight into it on
the current event loop no port, no uvicorn, no subprocess but middleware,
authentication, session management and SSE streaming all behave as in production.
Reach for `asgi_server` instead when a fixture must serve several tests that each
build their own client, or when a test needs raw HTTP access to the app.
Args:
server: FastMCP server instance.
transport: Transport type ("http", "streamable-http", or "sse").
path: URL path for the server (defaults to "/mcp", or "/sse" for SSE).
headers: HTTP headers to send with every request.
auth: Client authentication, as accepted by the HTTP transports.
**client_kwargs: Additional arguments forwarded to `Client`.
Yields:
A connected `Client`.
Example:
```python
from fastmcp import FastMCP
from fastmcp.utilities.tests import asgi_client
async def test_greet():
mcp = FastMCP("test")
@mcp.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
async with asgi_client(mcp) as client:
result = await client.call_tool("greet", {"name": "World"})
assert result.data == "Hello, World!"
```
"""
async with (
asgi_server(server, transport=transport, path=path) as running_server,
running_server.client(headers=headers, auth=auth, **client_kwargs) as client,
):
yield client
class HeadlessOAuth(OAuth):
"""
OAuth provider that bypasses browser interaction for testing.

View file

@ -126,6 +126,7 @@ env = [
markers = [
"integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
"client_process: marks tests that spawn client processes via stdio transport. These can create issues when run in the same CI environment as other subprocess-based tests.",
"subprocess_heavy: marks tests that spawn a fresh Python interpreter which imports FastMCP. Each one costs a full interpreter's memory and startup, so they run serially alongside client_process tests rather than competing with parallel xdist workers.",
"conformance: marks MCP conformance tests (require Node.js/npx)",
]
pythonpath = ["fastmcp_slim", "fastmcp_remote"]

View file

@ -156,6 +156,91 @@ class TestAutoMode:
assert client.initialize_result is not None
class TestNonConformantModernPeer:
"""A peer that answers ``server/discover`` but cannot actually serve the era.
``negotiate_auto`` accepts a probe that parses as the version-free
``DiscoverResult``, where ``resultType``/``ttlMs``/``cacheScope`` all carry
SDK-side defaults. Every request after adoption is checked against the strict
per-version surface, where those three fields are required. Left alone, a
server that omits them on ``server/discover`` passes the probe and then fails
every subsequent call, so ``auto`` would adopt an era the peer cannot serve.
GitHub's remote MCP server is a live example: it has adopted the SEP-2549
cache fields but not result tagging, so it answers ``server/discover``
with ``ttlMs``/``cacheScope`` and no ``resultType``.
"""
@staticmethod
def _discover_body(**envelope: Any) -> dict[str, Any]:
return {
"supportedVersions": [LATEST_MODERN_VERSION],
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
"serverInfo": {"name": "TestServer", "version": "1.0"},
**envelope,
}
@staticmethod
def _transport_answering(body: dict[str, Any], server) -> FastMCPTransport:
"""An in-memory transport whose ``server/discover`` returns ``body`` verbatim."""
class _FixedDiscoverTransport(FastMCPTransport):
@contextlib.asynccontextmanager
async def connect_session(
self, **session_kwargs: Unpack[SessionKwargs]
) -> AsyncIterator[ClientSession]:
async with super().connect_session(**session_kwargs) as session:
async def _fixed_discover(version: str) -> dict[str, Any]:
return body
session.send_discover = _fixed_discover # ty: ignore[invalid-assignment]
yield session
return _FixedDiscoverTransport(server)
@pytest.mark.parametrize(
"envelope",
[
pytest.param({}, id="no-envelope-fields"),
pytest.param(
{"ttlMs": 0, "cacheScope": "private"}, id="github-shape-no-resultType"
),
pytest.param({"resultType": "complete"}, id="no-cache-fields"),
],
)
async def test_non_conformant_discover_falls_back_to_handshake(
self, fastmcp_server, envelope
):
"""A discover result missing required 2026-07-28 fields is not modern evidence.
Rather than adopting an era the peer cannot serve, auto degrades to the
initialize handshake and the connection stays fully usable.
"""
transport = self._transport_answering(
self._discover_body(**envelope), fastmcp_server
)
async with Client(transport, mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert client.initialize_result is not None
# The connection works, which is the whole point of degrading.
assert await client.list_tools()
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.data == 3
async def test_conformant_discover_still_adopts_modern(self, fastmcp_server):
"""The conformance check must not reject a well-formed modern peer."""
transport = self._transport_answering(
self._discover_body(resultType="complete", ttlMs=0, cacheScope="private"),
fastmcp_server,
)
async with Client(transport, mode="auto") as client:
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.initialize_result is None
result = await client.call_tool("add", {"a": 1, "b": 2})
assert result.data == 3
class TestPinnedMode:
async def test_pinned_modern_adopts_without_probe(self, fastmcp_server):
"""Pinning the modern version adopts it directly; a synthesized

View file

@ -36,7 +36,7 @@ class TestSessionTaskErrorPropagation:
async def never_complete():
"""A coroutine that will never complete normally."""
await asyncio.sleep(1000)
await asyncio.Event().wait()
async def failing_session():
"""Simulates a session task that raises an error."""

View file

@ -0,0 +1,192 @@
"""A minimal MCP server spoken over stdio, using only the standard library.
This is a **test fixture**, not a real server. It exists so that subprocess
lifecycle tests (keep-alive, crash recovery, PID identity) can spawn many
short-lived servers without paying for `import fastmcp` in every child
process. Importing fastmcp and constructing a `FastMCP` instance costs
roughly 0.7s per spawn; this script starts in roughly 0.03s.
It implements only what those tests exercise: the `initialize` handshake,
`tools/list`, and `tools/call` for two trivial tools. Anything that needs
real FastMCP semantics (tool serialization, error handling, structured
output shapes) must use a real FastMCP server instead.
The response shapes below were captured from the wire of a real FastMCP
stdio server so that `CallToolResult.data` deserializes identically.
Usage:
python minimal_stdio_server.py [--exit-after-calls N]
With `--exit-after-calls N`, the `pid` tool schedules a clean `os._exit(0)`
shortly after its Nth invocation, simulating a server that shuts itself
down mid-session.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import threading
from typing import Any
INT_OUTPUT_SCHEMA: dict[str, Any] = {
"properties": {"result": {"type": "integer"}},
"required": ["result"],
"type": "object",
"x-fastmcp-wrap-result": True,
}
STR_OUTPUT_SCHEMA: dict[str, Any] = {
"properties": {"result": {"type": "string"}},
"required": ["result"],
"type": "object",
"x-fastmcp-wrap-result": True,
}
TOOLS: list[dict[str, Any]] = [
{
"name": "pid",
"description": "Gets PID of server",
"inputSchema": {
"properties": {},
"type": "object",
"additionalProperties": False,
},
"outputSchema": INT_OUTPUT_SCHEMA,
},
{
"name": "echo",
"description": "Echoes the message back",
"inputSchema": {
"properties": {"message": {"type": "string"}},
"required": ["message"],
"type": "object",
"additionalProperties": False,
},
"outputSchema": STR_OUTPUT_SCHEMA,
},
]
METHOD_NOT_FOUND = -32601
INVALID_PARAMS = -32602
def _wrapped_result(value: int | str) -> dict[str, Any]:
"""Mirror how FastMCP reports a scalar return value on the wire."""
return {
"_meta": {"fastmcp": {"wrap_result": True}},
"content": [{"type": "text", "text": str(value)}],
"isError": False,
"structuredContent": {"result": value},
}
class MinimalServer:
def __init__(self, exit_after_calls: int | None) -> None:
self.exit_after_calls = exit_after_calls
self.pid_call_count = 0
def send(self, message: dict[str, Any]) -> None:
sys.stdout.write(json.dumps(message) + "\n")
sys.stdout.flush()
def reply(self, request_id: Any, result: dict[str, Any]) -> None:
self.send({"jsonrpc": "2.0", "id": request_id, "result": result})
def reply_error(self, request_id: Any, code: int, message: str) -> None:
self.send(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": code, "message": message},
}
)
def handle_initialize(self, request_id: Any, params: dict[str, Any]) -> None:
# Echo the client's requested version back. The client rejects any
# version it did not ask for, and echoing keeps this fixture working
# across SDK protocol bumps without edits.
protocol_version = params.get("protocolVersion")
self.reply(
request_id,
{
"protocolVersion": protocol_version,
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "MinimalStdioServer", "version": "1.0.0"},
},
)
def handle_tools_call(self, request_id: Any, params: dict[str, Any]) -> None:
name = params.get("name")
arguments = params.get("arguments") or {}
if name == "pid":
self.pid_call_count += 1
pid = os.getpid()
if (
self.exit_after_calls is not None
and self.pid_call_count >= self.exit_after_calls
):
# Reply first, then exit shortly after, so the client sees a
# successful call followed by an unannounced clean shutdown.
self.reply(request_id, _wrapped_result(pid))
threading.Timer(0.1, lambda: os._exit(0)).start()
return
self.reply(request_id, _wrapped_result(pid))
return
if name == "echo":
message = arguments.get("message")
if not isinstance(message, str):
self.reply_error(request_id, INVALID_PARAMS, "message must be a string")
return
self.reply(request_id, _wrapped_result(message))
return
self.reply_error(request_id, INVALID_PARAMS, f"Unknown tool: {name}")
def handle(self, message: dict[str, Any]) -> None:
method = message.get("method")
request_id = message.get("id")
params = message.get("params") or {}
if request_id is None:
# Notification (e.g. notifications/initialized) — nothing to send.
return
if method == "initialize":
self.handle_initialize(request_id, params)
elif method == "ping":
self.reply(request_id, {})
elif method == "tools/list":
self.reply(request_id, {"tools": TOOLS})
elif method == "tools/call":
self.handle_tools_call(request_id, params)
else:
self.reply_error(request_id, METHOD_NOT_FOUND, f"Unknown method: {method}")
def run(self) -> None:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
message = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(message, dict):
self.handle(message)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--exit-after-calls", type=int, default=None)
parsed = parser.parse_args()
MinimalServer(exit_after_calls=parsed.exit_after_calls).run()
if __name__ == "__main__":
main()

View file

@ -7,6 +7,7 @@ and invoke user callbacks.
import asyncio
import time
from collections.abc import Callable
from datetime import datetime, timezone
import pytest
@ -16,6 +17,17 @@ from fastmcp import FastMCP
from fastmcp.client import Client
async def _wait_until(condition: Callable[[], bool], timeout: float = 5.0) -> None:
"""Poll until condition() is true or timeout elapses.
Used in place of a fixed sleep when waiting for an async callback or
notification to be delivered/dispatched after the awaited call returns.
"""
deadline = time.monotonic() + timeout
while not condition() and time.monotonic() < deadline:
await asyncio.sleep(0.005)
@pytest.fixture
async def task_notification_server():
"""Server that sends task status notifications."""
@ -23,8 +35,8 @@ async def task_notification_server():
@mcp.tool(task=True)
async def quick_task(value: int) -> int:
"""Quick background task."""
await asyncio.sleep(0.05)
"""Quick background task with a brief, measurable delay (contrast with instant_task)."""
await asyncio.sleep(0.01)
return value * 2
@mcp.tool(task=True)
@ -32,12 +44,6 @@ async def task_notification_server():
"""Background task that completes with no delay."""
return value * 2
@mcp.tool(task=True)
async def slow_task(duration: float = 0.2) -> str:
"""Slow background task."""
await asyncio.sleep(duration)
return "done"
@mcp.tool(task=True)
async def failing_task() -> str:
"""Task that fails."""
@ -93,8 +99,12 @@ async def test_callback_invoked_on_notification(task_notification_server):
# Wait for completion
await task.wait(timeout=2.0)
# Give callbacks a moment to fire
await asyncio.sleep(0.1)
# Wait for the status this test actually asserts on. Waiting merely for
# "some callback fired" would be satisfied by the earlier `working`
# notification and race the `completed` one.
await _wait_until(
lambda: any(s.status == "completed" for s in callback_invocations)
)
# Callback should have been invoked at least once
assert len(callback_invocations) > 0
@ -123,7 +133,7 @@ async def test_async_callback_invoked(task_notification_server):
await task.wait(timeout=2.0)
# Give async callbacks time to complete
await asyncio.sleep(0.2)
await _wait_until(lambda: len(callback_invocations) > 0)
# Async callback should have been invoked
assert len(callback_invocations) > 0
@ -147,7 +157,7 @@ async def test_multiple_callbacks_all_invoked(task_notification_server):
task.on_status_change(callback2)
await task.wait(timeout=2.0)
await asyncio.sleep(0.1)
await _wait_until(lambda: bool(callback1_calls) and bool(callback2_calls))
# Both callbacks should have been invoked
assert len(callback1_calls) > 0
@ -173,7 +183,7 @@ async def test_callback_error_doesnt_break_notification(task_notification_server
task.on_status_change(working_callback)
await task.wait(timeout=2.0)
await asyncio.sleep(0.1)
await _wait_until(lambda: bool(callback1_calls) and bool(callback2_calls))
# Failing callback was called (and errored)
assert len(callback1_calls) > 0
@ -240,7 +250,7 @@ async def test_fast_task_completion_delivered_via_notification(
assert result.data == 42
# Allow the completion notification to arrive and dispatch.
await asyncio.sleep(0.1)
await _wait_until(lambda: "completed" in received)
assert "completed" in received

View file

@ -71,7 +71,9 @@ async def test_task_id_operations_create_propagating_client_spans(
@server.tool(task=True)
async def slow_tool() -> str:
started.set()
await asyncio.sleep(10)
# Never completes on its own - the test cancels this task well
# before any real-time completion would matter.
await asyncio.Event().wait()
return "done"
async with Client(server, mode="legacy") as client:

View file

@ -216,7 +216,8 @@ async def test_user_binding_clobbering_task_method_is_rejected():
identifier = "test.example.com/clobber"
def notifications(self):
async def _handler(params: PingParams) -> None: ...
async def _handler(params: PingParams) -> None:
return None
return (
NotificationBinding(

View file

@ -8,6 +8,18 @@ from fastmcp.client.oauth_callback import (
from fastmcp.utilities.http import find_available_port
async def _wait_until_listening(server) -> None:
"""Poll until the callback server's socket is accepting connections.
uvicorn sets `Server.started = True` right after it binds and starts
listening on the socket, before `serve()` moves on to request handling,
so this is a deterministic readiness signal in place of a fixed sleep.
"""
with anyio.fail_after(5):
while not server.started:
await anyio.sleep(0.001)
async def test_oauth_callback_result_ignores_subsequent_callbacks():
"""Only the first callback should be captured in shared OAuth callback state."""
port = find_available_port()
@ -22,7 +34,7 @@ async def test_oauth_callback_result_ignores_subsequent_callbacks():
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
await anyio.sleep(0.05)
await _wait_until_listening(server)
async with httpx2.AsyncClient() as client:
first = await client.get(
@ -72,7 +84,7 @@ async def test_oauth_callback_result_captures_iss():
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
await anyio.sleep(0.05)
await _wait_until_listening(server)
async with httpx2.AsyncClient() as client:
response = await client.get(
@ -112,7 +124,7 @@ async def test_oauth_callback_result_captures_iss_on_error():
async with anyio.create_task_group() as tg:
tg.start_soon(server.serve)
await anyio.sleep(0.05)
await _wait_until_listening(server)
async with httpx2.AsyncClient() as client:
response = await client.get(

View file

@ -285,26 +285,27 @@ class TestAutomaticToolLoop:
async def test_concurrent_tool_execution_default_sequential(self):
"""Test that tools execute sequentially by default."""
import asyncio
import time
from mcp_types import CreateMessageResultWithTools, ToolUseContent
execution_order: list[tuple[str, float]] = []
# Ordering is guaranteed structurally (the loop awaits each tool call
# to completion before starting the next when tool_concurrency is
# None), so no real delay is needed to prove it - a single
# `asyncio.sleep(0)` still yields control to the event loop.
execution_order: list[str] = []
async def slow_tool_a(x: int) -> int:
"""Slow tool A."""
start = time.time()
execution_order.append(("tool_a_start", start))
await asyncio.sleep(0.1)
execution_order.append(("tool_a_end", time.time()))
execution_order.append("tool_a_start")
await asyncio.sleep(0)
execution_order.append("tool_a_end")
return x * 2
async def slow_tool_b(y: int) -> int:
"""Slow tool B."""
start = time.time()
execution_order.append(("tool_b_start", start))
await asyncio.sleep(0.1)
execution_order.append(("tool_b_end", time.time()))
execution_order.append("tool_b_start")
await asyncio.sleep(0)
execution_order.append("tool_b_end")
return y + 10
call_count = 0
@ -359,30 +360,39 @@ class TestAutomaticToolLoop:
assert result.data == "Done!"
# Verify sequential execution: tool_a must complete before tool_b starts
events = [e[0] for e in execution_order]
assert events == ["tool_a_start", "tool_a_end", "tool_b_start", "tool_b_end"]
assert execution_order == [
"tool_a_start",
"tool_a_end",
"tool_b_start",
"tool_b_end",
]
async def test_concurrent_tool_execution_unlimited(self):
"""Test unlimited parallel tool execution with tool_concurrency=0."""
import asyncio
import time
from mcp_types import CreateMessageResultWithTools, ToolUseContent
execution_times: dict[str, dict[str, float]] = {}
# tool_a blocks on an event that only tool_b sets. This is only
# satisfiable if both tools are genuinely running concurrently: under
# sequential execution tool_b would never start (tool_a would never
# finish awaiting it) and the test would fail via the wait_for
# timeout rather than racing on wall-clock timestamps.
execution_order: list[str] = []
tool_b_started = asyncio.Event()
async def slow_tool_a(x: int) -> int:
"""Slow tool A."""
execution_times["tool_a"] = {"start": time.time()}
await asyncio.sleep(0.1)
execution_times["tool_a"]["end"] = time.time()
execution_order.append("tool_a_start")
await asyncio.wait_for(tool_b_started.wait(), timeout=1.0)
execution_order.append("tool_a_end")
return x * 2
async def slow_tool_b(y: int) -> int:
"""Slow tool B."""
execution_times["tool_b"] = {"start": time.time()}
await asyncio.sleep(0.1)
execution_times["tool_b"]["end"] = time.time()
execution_order.append("tool_b_start")
tool_b_started.set()
execution_order.append("tool_b_end")
return y + 10
call_count = 0
@ -436,26 +446,41 @@ class TestAutomaticToolLoop:
result = await client.call_tool("test_tool", {})
assert result.data == "Done!"
# Verify parallel execution: both tools should overlap in time
assert "tool_a" in execution_times
assert "tool_b" in execution_times
# tool_b should start before tool_a finishes (overlap)
assert execution_times["tool_b"]["start"] < execution_times["tool_a"]["end"]
# Verify parallel execution: tool_b started and finished entirely
# inside tool_a's blocked wait, which is only possible if the two
# tools were running concurrently.
assert execution_order == [
"tool_a_start",
"tool_b_start",
"tool_b_end",
"tool_a_end",
]
async def test_concurrent_tool_execution_bounded(self):
"""Test bounded parallel execution with tool_concurrency=2."""
import asyncio
import time
from mcp_types import CreateMessageResultWithTools, ToolUseContent
execution_order: list[tuple[str, float]] = []
# tool_1 and tool_2 each block until *both* have started, which is
# only possible if two slots are occupied simultaneously (proving
# concurrency=2 admits two tools at once). tool_3 has no blocking
# branch, so its appearance in the log tells us when the real
# semaphore in the implementation let it in - only after a slot
# frees, i.e. after tool_1 or tool_2 finishes.
execution_order: list[str] = []
both_started = asyncio.Event()
started_names: set[str] = set()
async def slow_tool(name: str, duration: float = 0.1) -> str:
"""Generic slow tool."""
execution_order.append((f"{name}_start", time.time()))
await asyncio.sleep(duration)
execution_order.append((f"{name}_end", time.time()))
async def slow_tool(name: str) -> str:
"""Generic tool used to observe bounded concurrency."""
execution_order.append(f"{name}_start")
if name in ("tool_1", "tool_2"):
started_names.add(name)
if {"tool_1", "tool_2"} <= started_names:
both_started.set()
await asyncio.wait_for(both_started.wait(), timeout=1.0)
execution_order.append(f"{name}_end")
return f"{name} done"
call_count = 0
@ -475,19 +500,19 @@ class TestAutomaticToolLoop:
type="tool_use",
id="call_1",
name="slow_tool",
input={"name": "tool_1", "duration": 0.1},
input={"name": "tool_1"},
),
ToolUseContent(
type="tool_use",
id="call_2",
name="slow_tool",
input={"name": "tool_2", "duration": 0.1},
input={"name": "tool_2"},
),
ToolUseContent(
type="tool_use",
id="call_3",
name="slow_tool",
input={"name": "tool_3", "duration": 0.05},
input={"name": "tool_3"},
),
],
model="test-model",
@ -517,38 +542,39 @@ class TestAutomaticToolLoop:
assert result.data == "Done!"
# Verify that at most 2 tools run concurrently
events = [e[0] for e in execution_order]
# First 2 tools should start before either ends
assert events[0] in ["tool_1_start", "tool_2_start"]
assert events[1] in ["tool_1_start", "tool_2_start"]
assert execution_order[0] in ["tool_1_start", "tool_2_start"]
assert execution_order[1] in ["tool_1_start", "tool_2_start"]
# Third tool should start after at least one of the first two finishes
tool_3_start_idx = events.index("tool_3_start")
tool_3_start_idx = execution_order.index("tool_3_start")
assert (
"tool_1_end" in events[:tool_3_start_idx]
or "tool_2_end" in events[:tool_3_start_idx]
"tool_1_end" in execution_order[:tool_3_start_idx]
or "tool_2_end" in execution_order[:tool_3_start_idx]
)
async def test_sequential_tool_forces_sequential_execution(self):
"""Test that sequential=True forces all tools to execute sequentially."""
import asyncio
import time
from mcp_types import CreateMessageResultWithTools, ToolUseContent
execution_order: list[tuple[str, float]] = []
# A sequential=True tool in the batch forces the whole batch through
# the plain for-loop path (see run.py's `requires_sequential`), so
# ordering is guaranteed structurally and no real delay is needed.
execution_order: list[str] = []
async def normal_tool(x: int) -> int:
"""Normal tool."""
execution_order.append(("normal_start", time.time()))
await asyncio.sleep(0.05)
execution_order.append(("normal_end", time.time()))
execution_order.append("normal_start")
await asyncio.sleep(0)
execution_order.append("normal_end")
return x * 2
async def sequential_tool(y: int) -> int:
"""Sequential tool."""
execution_order.append(("sequential_start", time.time()))
await asyncio.sleep(0.05)
execution_order.append(("sequential_end", time.time()))
execution_order.append("sequential_start")
await asyncio.sleep(0)
execution_order.append("sequential_end")
return y + 10
call_count = 0
@ -607,16 +633,15 @@ class TestAutomaticToolLoop:
assert result.data == "Done!"
# Verify sequential execution: first tool must complete before second starts
events = [e[0] for e in execution_order]
assert events[0] in ["normal_start", "sequential_start"]
assert events[1] in ["normal_end", "sequential_end"]
assert execution_order[0] in ["normal_start", "sequential_start"]
assert execution_order[1] in ["normal_end", "sequential_end"]
# Ensure the second tool starts after the first ends
if events[0] == "normal_start":
assert events[1] == "normal_end"
assert events[2] == "sequential_start"
if execution_order[0] == "normal_start":
assert execution_order[1] == "normal_end"
assert execution_order[2] == "sequential_start"
else:
assert events[1] == "sequential_end"
assert events[2] == "normal_start"
assert execution_order[1] == "sequential_end"
assert execution_order[2] == "normal_start"
async def test_concurrent_tool_execution_error_handling(self):
"""Test that errors are captured per-tool in parallel execution."""
@ -695,9 +720,26 @@ class TestAutomaticToolLoop:
ToolUseContent,
)
async def tool_with_delay(value: int, delay: float) -> int:
"""Tool that takes variable time."""
await asyncio.sleep(delay)
# Chain events so the tools finish in a different order (2, 3, 1)
# than they were called (1, 2, 3), without depending on real delays:
# tool 2 finishes immediately and unblocks tool 3, which finishes and
# unblocks tool 1. This only resolves if all three run concurrently -
# under sequential execution tool 1 would deadlock waiting on tool 3,
# which itself would never have been started yet.
tool_2_done = asyncio.Event()
tool_3_done = asyncio.Event()
async def tool_with_delay(value: int) -> int:
"""Tool that finishes out of call order."""
if value == 1:
await asyncio.wait_for(tool_3_done.wait(), timeout=1.0)
elif value == 3:
await asyncio.wait_for(tool_2_done.wait(), timeout=1.0)
if value == 2:
tool_2_done.set()
elif value == 3:
tool_3_done.set()
return value
messages_received: list[list[SamplingMessage]] = []
@ -708,7 +750,7 @@ class TestAutomaticToolLoop:
messages_received.append(list(messages))
if len(messages_received) == 1:
# Tools with different delays - later tools finish first
# Call order is 1, 2, 3 but they finish out of order (2, 3, 1)
return CreateMessageResultWithTools(
role="assistant",
content=[
@ -716,19 +758,19 @@ class TestAutomaticToolLoop:
type="tool_use",
id="call_1",
name="tool_with_delay",
input={"value": 1, "delay": 0.15},
input={"value": 1},
),
ToolUseContent(
type="tool_use",
id="call_2",
name="tool_with_delay",
input={"value": 2, "delay": 0.05},
input={"value": 2},
),
ToolUseContent(
type="tool_use",
id="call_3",
name="tool_with_delay",
input={"value": 3, "delay": 0.1},
input={"value": 3},
),
],
model="test-model",

View file

@ -64,6 +64,7 @@ async def test_multiserver_config_requires_server_for_now() -> None:
pass
@pytest.mark.subprocess_heavy
def test_bare_slim_import_needs_only_mcp_types() -> None:
"""A bare `fastmcp-slim` install ships `mcp-types` but not the full `mcp` SDK.

View file

@ -11,7 +11,7 @@ from fastmcp.client.transports import SSETransport
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.http import create_sse_app
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_async
from fastmcp.utilities.tests import ASGIServer, asgi_server
def create_test_server() -> FastMCP:
@ -63,24 +63,22 @@ def create_test_server() -> FastMCP:
@pytest.fixture
async def sse_server():
"""Start a test server with SSE transport and return its URL."""
"""Start a test server with SSE transport, in-process."""
server = create_test_server()
async with run_server_async(server, transport="sse") as url:
yield url
async with asgi_server(server, transport="sse") as running_server:
yield running_server
async def test_ping(sse_server: str):
async def test_ping(sse_server: ASGIServer):
"""Test pinging the server."""
async with Client(transport=SSETransport(sse_server)) as client:
async with sse_server.client() as client:
result = await client.ping()
assert result is True
async def test_http_headers(sse_server: str):
async def test_http_headers(sse_server: ASGIServer):
"""Test getting HTTP headers from the server."""
async with Client(
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
@ -90,10 +88,10 @@ async def test_http_headers(sse_server: str):
@pytest.fixture
async def sse_server_custom_path():
"""Start a test server with SSE on a custom path."""
"""Start a test server with SSE on a custom path, in-process."""
server = create_test_server()
async with run_server_async(server, transport="sse", path="/help") as url:
yield url
async with asgi_server(server, transport="sse", path="/help") as running_server:
yield running_server
@pytest.fixture
@ -140,9 +138,9 @@ async def nested_sse_server():
pass
async def test_run_server_on_path(sse_server_custom_path: str):
async def test_run_server_on_path(sse_server_custom_path: ASGIServer):
"""Test running server on a custom path."""
async with Client(transport=SSETransport(sse_server_custom_path)) as client:
async with sse_server_custom_path.client() as client:
result = await client.ping()
assert result is True
@ -159,42 +157,33 @@ async def test_nested_sse_server_resolves_correctly(nested_sse_server: str):
reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
)
class TestTimeout:
async def test_timeout(self, sse_server: str):
async def test_timeout(self, sse_server: ASGIServer):
with pytest.raises(
MCPError,
match="timed out",
):
async with Client(
transport=SSETransport(sse_server),
timeout=0.03,
) as client:
async with sse_server.client(timeout=0.03) as client:
await client.call_tool("sleep", {"seconds": 0.1})
async def test_timeout_tool_call(self, sse_server: str):
async with Client(transport=SSETransport(sse_server)) as client:
async def test_timeout_tool_call(self, sse_server: ASGIServer):
async with sse_server.client() as client:
with pytest.raises(MCPError, match="timed out"):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03)
async def test_timeout_tool_call_overrides_client_timeout_if_lower(
self, sse_server: str
self, sse_server: ASGIServer
):
async with Client(
transport=SSETransport(sse_server),
timeout=2,
) as client:
async with sse_server.client(timeout=2) as client:
with pytest.raises(MCPError, match="timed out"):
await client.call_tool("sleep", {"seconds": 0.1}, timeout=0.03)
async def test_timeout_client_timeout_does_not_override_tool_call_timeout_if_lower(
self, sse_server: str
self, sse_server: ASGIServer
):
"""
With SSE, the tool call timeout always takes precedence over the client.
Note: on Windows, the behavior appears unpredictable.
"""
async with Client(
transport=SSETransport(sse_server),
timeout=0.5,
) as client:
async with sse_server.client(timeout=0.5) as client:
await client.call_tool("sleep", {"seconds": 0.8}, timeout=2)

View file

@ -2,14 +2,23 @@ import asyncio
import gc
import inspect
import os
import time
import weakref
from pathlib import Path
import psutil
import pytest
from mcp.shared.exceptions import MCPError
from fastmcp import Client
from fastmcp.client.transports import PythonStdioTransport, StdioTransport
# A pure-stdlib MCP server used by the process-lifecycle tests below. It starts
# in ~0.03s instead of the ~0.7s a real FastMCP server needs, which matters
# because these tests spawn several subprocesses each. See its docstring for
# what it does and does not implement.
MINIMAL_STDIO_SERVER = Path(__file__).parent / "minimal_stdio_server.py"
def running_under_debugger():
return os.environ.get("DEBUGPY_RUNNING") == "true"
@ -24,26 +33,48 @@ def gc_collect_harder():
gc.collect()
async def wait_for_log_content(
log_file_path, expected: str, timeout: float = 2.0
) -> str:
"""Poll a log file until it contains the expected text.
The subprocess's stderr is redirected straight to the file at the OS
level (no async pump on our side to synchronize on), so poll for the
content instead of sleeping a fixed amount and hoping it landed.
"""
async def _poll() -> str:
while True:
content = log_file_path.read_text()
if expected in content:
return content
await asyncio.sleep(0.01)
return await asyncio.wait_for(_poll(), timeout=timeout)
async def wait_for_process_exit(pid: int | None, timeout: float = 5.0) -> None:
"""Poll until the given pid is gone, failing clearly if it never exits.
The subprocesses under test self-terminate within a fraction of a second,
so a bounded poll costs nothing and turns a hung teardown into a named
failure instead of an opaque suite-level timeout.
"""
assert pid is not None
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
psutil.Process(pid)
except psutil.NoSuchProcess:
return
await asyncio.sleep(0.01)
pytest.fail(f"Subprocess {pid} was still alive after {timeout}s")
class TestParallelCalls:
@pytest.fixture
def stdio_script(self, tmp_path):
script = inspect.cleandoc('''
import os
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool
def pid() -> int:
"""Gets PID of server"""
return os.getpid()
if __name__ == "__main__":
mcp.run()
''')
script_file = tmp_path / "stdio.py"
script_file.write_text(script)
return script_file
def stdio_script(self):
return MINIMAL_STDIO_SERVER
async def test_parallel_calls(self, stdio_script):
from fastmcp.server import create_proxy
@ -69,24 +100,8 @@ class TestKeepAlive:
# https://github.com/PrefectHQ/fastmcp/issues/581
@pytest.fixture
def stdio_script(self, tmp_path):
script = inspect.cleandoc('''
import os
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool
def pid() -> int:
"""Gets PID of server"""
return os.getpid()
if __name__ == "__main__":
mcp.run()
''')
script_file = tmp_path / "stdio.py"
script_file.write_text(script)
return script_file
def stdio_script(self):
return MINIMAL_STDIO_SERVER
async def test_keep_alive_default_true(self):
client = Client(transport=StdioTransport(command="python", args=[""]))
@ -160,10 +175,7 @@ class TestKeepAlive:
# This test may fail/hang while debugging because the debugger holds a reference to the underlying transport
with pytest.raises(psutil.NoSuchProcess):
while True:
psutil.Process(pid)
await asyncio.sleep(0.1)
await wait_for_process_exit(pid)
async def test_keep_alive_false_exit_scope_kills_server(self, stdio_script):
pid: int | None = None
@ -181,10 +193,7 @@ class TestKeepAlive:
await test_server()
with pytest.raises(psutil.NoSuchProcess):
while True:
psutil.Process(pid)
await asyncio.sleep(0.1)
await wait_for_process_exit(pid)
async def test_keep_alive_false_starts_new_session_across_multiple_calls(
self, stdio_script
@ -268,24 +277,8 @@ class TestSubprocessCrashRecovery:
INIT_TIMEOUT = 3
@pytest.fixture
def stdio_script(self, tmp_path):
script = inspect.cleandoc('''
import os
from fastmcp import FastMCP
mcp = FastMCP()
@mcp.tool
def pid() -> int:
"""Gets PID of server"""
return os.getpid()
if __name__ == "__main__":
mcp.run()
''')
script_file = tmp_path / "stdio.py"
script_file.write_text(script)
return script_file
def stdio_script(self):
return MINIMAL_STDIO_SERVER
async def test_keep_alive_recovers_after_subprocess_crash(self, stdio_script):
"""When keep_alive=True and the subprocess dies, the next connection should start a fresh subprocess."""
@ -431,61 +424,77 @@ class TestSubprocessCrashRecovery:
# Kill the subprocess
psutil.Process(pid1).kill()
# Fire several concurrent requests — all should fail, none should hang
# Fire several concurrent requests. Depending on how quickly the dead
# session is detected and a replacement spawned, each caller either
# fails cleanly or lands on the fresh subprocess — with a fast-starting
# server, recovery can beat all five requests and the crash is fully
# transparent. What must never happen: a hang (gather returning is the
# proof), or a "success" served by the killed process.
tasks = [proxy.call_tool("pid") for _ in range(5)]
results = await asyncio.gather(*tasks, return_exceptions=True)
errors = [r for r in results if isinstance(r, Exception)]
assert len(errors) > 0
for r in results:
if not isinstance(r, Exception):
served_by = int(r.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
assert served_by != pid1, (
"call reported success from the killed subprocess"
)
# Recovery: a subsequent request should succeed
# Recovery: a subsequent request must succeed on a fresh subprocess
result = await proxy.call_tool("pid")
pid2 = int(result.content[0].text) # type: ignore[union-attr] # ty:ignore[unresolved-attribute]
assert pid1 != pid2
async def test_clean_exit_recovers(self, tmp_path):
async def test_clean_exit_recovers(self):
"""Recovery works when the subprocess exits cleanly (exit code 0), not just crashes."""
script = tmp_path / "exit_script.py"
script.write_text(
inspect.cleandoc('''
import os, sys, threading
from fastmcp import FastMCP
mcp = FastMCP()
call_count = 0
@mcp.tool
def pid_then_exit() -> int:
"""Returns PID, exits cleanly after second call."""
global call_count
call_count += 1
pid = os.getpid()
if call_count >= 2:
threading.Timer(0.1, lambda: os._exit(0)).start()
return pid
if __name__ == "__main__":
mcp.run()
''')
)
client = Client(
transport=PythonStdioTransport(script_path=script),
transport=PythonStdioTransport(
script_path=MINIMAL_STDIO_SERVER,
args=["--exit-after-calls", "2"],
),
init_timeout=self.INIT_TIMEOUT,
)
async with client:
result1 = await client.call_tool("pid_then_exit")
result1 = await client.call_tool("pid")
pid1: int = result1.data
# Second call triggers delayed clean exit
await client.call_tool("pid_then_exit")
await asyncio.sleep(0.3)
await client.call_tool("pid")
# Recovery after clean exit
async with client:
result2 = await client.call_tool("pid_then_exit")
pid2: int = result2.data
# Wait for the subprocess to actually exit (it self-terminates
# via a background timer ~0.1s after the second call) instead
# of blindly sleeping past the worst case.
await wait_for_process_exit(pid1)
# Recovery after clean exit.
#
# The transport only notices a dead session once the SDK dispatcher's
# read loop has observed EOF on the subprocess's stdout and set its
# `_closed` flag (see `StdioTransport._is_session_dead`). The process
# being gone does not imply that detection has happened yet: EOF has to
# travel from the OS pipe through anyio's stream plumbing and then be
# picked up by a separate read-loop task. On a loaded machine — notably
# Windows CI running xdist workers on two cores — that can land after
# `connect()` samples the flag, so the first attempt is routed to the
# stale session and fails with CONNECTION_CLOSED, which in turn tears
# the session down so the next attempt reconnects.
#
# The crash tests above encode this same one-failure-then-recover
# contract explicitly with `pytest.raises`. Here the failure is
# timing-dependent rather than guaranteed, so retry once instead: the
# invariant under test is that a cleanly-exited server is replaced by a
# fresh subprocess, not how many attempts EOF detection costs.
pid2: int | None = None
for _ in range(2):
try:
async with client:
result2 = await client.call_tool("pid")
pid2 = result2.data
break
except (MCPError, RuntimeError):
continue
assert pid2 is not None, "Client did not recover after a clean subprocess exit"
assert pid1 != pid2
async def test_crash_during_initialization(self, tmp_path):
@ -508,20 +517,13 @@ class TestSubprocessCrashRecovery:
async with client:
pass
# Write a working script to the same path
# Replace the same path with a working server. It delegates to the
# minimal stdio server so the retry doesn't pay for a fastmcp import.
crash_script.write_text(
inspect.cleandoc("""
import os
from fastmcp import FastMCP
inspect.cleandoc(f"""
import runpy
mcp = FastMCP()
@mcp.tool
def pid() -> int:
return os.getpid()
if __name__ == "__main__":
mcp.run()
runpy.run_path({str(MINIMAL_STDIO_SERVER)!r}, run_name="__main__")
""")
)
@ -531,7 +533,16 @@ class TestSubprocessCrashRecovery:
assert isinstance(result.data, int)
@pytest.mark.subprocess_heavy
class TestLogFile:
"""Stderr capture, proven against a real FastMCP server.
Unlike the rest of this module these spawn a full `import fastmcp`
interpreter rather than the minimal stdlib server, because the point is
that the log file captures a real server's stderr. That costs ~0.7s per
spawn, so they run in the serial CI step.
"""
@pytest.fixture
def stdio_script_with_stderr(self, tmp_path):
script = inspect.cleandoc('''
@ -594,10 +605,7 @@ class TestLogFile:
async with client:
await client.call_tool("write_error", {"message": "Test error message"})
# Need to wait a bit for stderr to flush
await asyncio.sleep(0.1)
content = log_file_path.read_text()
content = await wait_for_log_content(log_file_path, "Test error message")
assert "Test error message" in content
async def test_log_file_captures_stderr_output_with_textio(
@ -617,10 +625,10 @@ class TestLogFile:
"write_error", {"message": "Test error with TextIO"}
)
# Need to wait a bit for stderr to flush
await asyncio.sleep(0.1)
content = await wait_for_log_content(
log_file_path, "Test error with TextIO"
)
content = log_file_path.read_text()
assert "Test error with TextIO" in content
async def test_log_file_none_uses_default_behavior(

View file

@ -13,7 +13,7 @@ from fastmcp.client import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_async
from fastmcp.utilities.tests import ASGIServer, asgi_server
def create_test_server() -> FastMCP:
@ -90,8 +90,8 @@ async def streamable_http_server(request):
fastmcp.settings.stateless_http = True
server = create_test_server()
async with run_server_async(server) as url:
yield url
async with asgi_server(server) as running_server:
yield running_server
if stateless_http:
fastmcp.settings.stateless_http = False
@ -101,8 +101,8 @@ async def streamable_http_server(request):
async def streamable_http_server_with_streamable_http_alias():
"""Test that the "streamable-http" transport alias works."""
server = create_test_server()
async with run_server_async(server, transport="streamable-http") as url:
yield url
async with asgi_server(server, transport="streamable-http") as running_server:
yield running_server
@pytest.fixture
@ -147,35 +147,30 @@ async def nested_server():
await asyncio.wait_for(server_task, timeout=2.0)
async def test_ping(streamable_http_server: str):
async def test_ping(streamable_http_server: ASGIServer):
"""Test pinging the server."""
async with Client(
transport=StreamableHttpTransport(streamable_http_server), mode="legacy"
) as client:
# `ping` is a handshake-era method, so this pins the legacy era.
async with streamable_http_server.client(mode="legacy") as client:
result = await client.ping()
assert result is True
async def test_ping_with_streamable_http_alias(
streamable_http_server_with_streamable_http_alias: str,
streamable_http_server_with_streamable_http_alias: ASGIServer,
):
"""Test pinging the server."""
async with Client(
transport=StreamableHttpTransport(
streamable_http_server_with_streamable_http_alias
),
mode="legacy",
# `ping` is a handshake-era method, so this pins the legacy era.
async with streamable_http_server_with_streamable_http_alias.client(
mode="legacy"
) as client:
result = await client.ping()
assert result is True
async def test_http_headers(streamable_http_server: str):
async def test_http_headers(streamable_http_server: ASGIServer):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(
streamable_http_server, headers={"X-DEMO-HEADER": "ABC"}
)
async with streamable_http_server.client(
headers={"X-DEMO-HEADER": "ABC"}
) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
@ -184,9 +179,9 @@ async def test_http_headers(streamable_http_server: str):
assert json_result["x-demo-header"] == "ABC"
async def test_session_id_callback(streamable_http_server: str):
async def test_session_id_callback(streamable_http_server: ASGIServer):
"""Test getting mcp-session-id from the transport."""
transport = StreamableHttpTransport(streamable_http_server)
transport = streamable_http_server.transport()
assert transport.get_session_id() is None
async with Client(transport=transport, mode="legacy"):
session_id = transport.get_session_id()
@ -194,13 +189,12 @@ async def test_session_id_callback(streamable_http_server: str):
@pytest.mark.parametrize("streamable_http_server", [True, False], indirect=True)
async def test_greet_with_progress_tool(streamable_http_server: str):
async def test_greet_with_progress_tool(streamable_http_server: ASGIServer):
"""Test calling the greet tool."""
progress_handler = AsyncMock(return_value=None)
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
progress_handler=progress_handler,
async with streamable_http_server.client(
progress_handler=progress_handler
) as client:
result = await client.call_tool("greet_with_progress", {"name": "Alice"})
assert result.data == "Hello, Alice!"
@ -214,7 +208,7 @@ async def test_greet_with_progress_tool(streamable_http_server: str):
@pytest.mark.parametrize("streamable_http_server", [True, False], indirect=True)
async def test_elicitation_tool(streamable_http_server: str, request):
async def test_elicitation_tool(streamable_http_server: ASGIServer, request):
"""Test calling the elicitation tool in both stateless and stateful modes."""
async def elicitation_handler(message, response_type, params, ctx):
@ -224,31 +218,28 @@ async def test_elicitation_tool(streamable_http_server: str, request):
if stateless_http:
pytest.xfail("Elicitation is not supported in stateless HTTP mode")
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
elicitation_handler=elicitation_handler,
mode="legacy",
# Server-initiated elicitation is handshake-era only.
async with streamable_http_server.client(
elicitation_handler=elicitation_handler, mode="legacy"
) as client:
result = await client.call_tool("elicit")
assert result.data == "You said your name was: Alice!"
@pytest.mark.parametrize("streamable_http_server", [True], indirect=True)
async def test_stateless_http_rejects_get_sse(streamable_http_server: str):
async def test_stateless_http_rejects_get_sse(streamable_http_server: ASGIServer):
"""Stateless servers should reject GET SSE requests with 405."""
import httpx2
async with httpx2.AsyncClient() as http_client:
response = await http_client.get(streamable_http_server)
async with streamable_http_server.http_client() as http_client:
response = await http_client.get(streamable_http_server.url)
assert response.status_code == 405
@pytest.mark.parametrize("streamable_http_server", [True], indirect=True)
async def test_stateless_http_still_accepts_post(streamable_http_server: str):
async def test_stateless_http_still_accepts_post(
streamable_http_server: ASGIServer,
):
"""Stateless servers should still handle POST requests normally."""
async with Client(
transport=StreamableHttpTransport(streamable_http_server)
) as client:
async with streamable_http_server.client() as client:
result = await client.call_tool("greet", {"name": "World"})
assert result.data == "Hello, World!"
@ -267,32 +258,25 @@ async def test_nested_streamable_http_server_resolves_correctly(nested_server: s
reason="Timeout tests are flaky on Windows. Timeouts *are* supported but the tests are unreliable.",
)
class TestTimeout:
async def test_timeout(self, streamable_http_server: str):
async def test_timeout(self, streamable_http_server: ASGIServer):
# note this transport behaves differently than others and raises
# MCPError from the *client* context. Pinned to legacy: on a modern
# (server/discover) connection a connect-time timeout surfaces as a raw
# httpx.ReadTimeout from the probe rather than a wrapped MCPError.
with pytest.raises(MCPError, match="timed out"):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
timeout=0.02,
mode="legacy",
async with streamable_http_server.client(
timeout=0.02, mode="legacy"
) as client:
await client.call_tool("sleep", {"seconds": 0.05})
async def test_timeout_tool_call(self, streamable_http_server: str):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
) as client:
async def test_timeout_tool_call(self, streamable_http_server: ASGIServer):
async with streamable_http_server.client() as client:
with pytest.raises(MCPError):
await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1)
async def test_timeout_tool_call_overrides_client_timeout(
self, streamable_http_server: str
self, streamable_http_server: ASGIServer
):
async with Client(
transport=StreamableHttpTransport(streamable_http_server),
timeout=2,
) as client:
async with streamable_http_server.client(timeout=2) as client:
with pytest.raises(MCPError):
await client.call_tool("sleep", {"seconds": 0.2}, timeout=0.1)

View file

@ -116,9 +116,9 @@ async def test_error_handling() -> str:
async def test_tool_with_logging(ctx: Context) -> str:
"""Sends log notifications during execution."""
await ctx.info("Tool execution started")
await asyncio.sleep(0.05)
await asyncio.sleep(0.01)
await ctx.info("Tool processing data")
await asyncio.sleep(0.05)
await asyncio.sleep(0.01)
await ctx.info("Tool execution completed")
return "Logging test complete."
@ -127,9 +127,9 @@ async def test_tool_with_logging(ctx: Context) -> str:
async def test_tool_with_progress(ctx: Context) -> str:
"""Reports progress notifications."""
await ctx.report_progress(0, 100)
await asyncio.sleep(0.05)
await asyncio.sleep(0.01)
await ctx.report_progress(50, 100)
await asyncio.sleep(0.05)
await asyncio.sleep(0.01)
await ctx.report_progress(100, 100)
return "Progress test complete."

View file

@ -55,7 +55,7 @@ def conformance_server(_require_npx):
with socket.create_connection((HOST, port), timeout=1):
break
except OSError:
time.sleep(0.1)
time.sleep(0.01)
else:
pytest.fail("Conformance server did not start in time")

View file

@ -85,19 +85,42 @@ def enable_fastmcp_logger_propagation(caplog):
root_logger.propagate = original_propagate
@pytest.fixture(scope="session")
def _settings_home_root(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""Session-scoped (i.e. per xdist-worker) base directory for isolated
settings.home directories.
Created once via ``tmp_path_factory`` so ``isolate_settings_home`` can
carve out a per-test subdirectory with a plain, cheap ``mkdir`` instead
of requesting a fresh ``tmp_path`` (which every test would otherwise pay
for, autouse) on every single test.
"""
return tmp_path_factory.mktemp("fastmcp-test-home")
@pytest.fixture(autouse=True)
def isolate_settings_home(tmp_path: Path):
def isolate_settings_home(_settings_home_root: Path):
"""Ensure each test uses an isolated settings.home directory.
This prevents file locking issues when multiple tests share the same
storage directory in settings.home / "oauth-proxy".
storage directory in settings.home / "oauth-proxy". That collision is
not hypothetical: most oauth-proxy tests construct their proxy with the
same hardcoded jwt_signing_key ("test-secret"), and the storage
directory's name is a fingerprint derived from that key -- so any two
tests reusing it resolve to the *same* subdirectory. Reusing a single
settings.home across the whole session/worker would let one test's
persisted client/token state leak into the next, even though the tests
run sequentially within a worker. A fresh subdirectory per test avoids
that leakage while a session-scoped root avoids paying tmp_path's
per-test overhead (numbering, test-id sanitization, retention-policy
bookkeeping) for the ~99% of tests that never touch this directory.
Also sets a fast Docket polling interval for tests the default 50ms
is fine for production but still adds ~25ms average pickup latency per
task. 10ms makes task tests near-instant.
"""
test_home = tmp_path / "fastmcp-test-home"
test_home.mkdir(exist_ok=True)
test_home = _settings_home_root / secrets.token_hex(8)
test_home.mkdir()
with temporary_settings(
home=test_home,

View file

@ -359,8 +359,10 @@ async def test_github_oauth_with_mock(github_client_with_mock: Client):
"""Test complete GitHub OAuth flow with mocked callback."""
async with github_client_with_mock:
# Test that we can ping the server (requires successful OAuth)
assert await github_client_with_mock.ping()
# Reaching the server at all requires successful OAuth. `list_tools` stands
# in for `ping` here because it works in either protocol era, and a default
# client negotiates the modern one, which has no `ping` method.
assert await github_client_with_mock.list_tools()
# Test that we can call protected tools
result = await github_client_with_mock.call_tool("get_protected_data", {})

View file

@ -24,6 +24,13 @@ pytestmark = pytest.mark.xfail(
@pytest.fixture(name="streamable_http_client")
def fixture_streamable_http_client() -> Client[StreamableHttpTransport]:
"""A default client, so this suite exercises `mode="auto"` against a real peer.
GitHub answers `server/discover` but has not adopted result tagging, so its
result envelope is not conformant with the modern version it advertises. The
client's conformance check catches that at connect time and degrades to the
initialize handshake, which is why these tests behave as they always have.
"""
assert FASTMCP_GITHUB_TOKEN is not None
return Client(
@ -34,6 +41,20 @@ def fixture_streamable_http_client() -> Client[StreamableHttpTransport]:
)
@pytest.fixture(name="legacy_client")
def fixture_legacy_client() -> Client[StreamableHttpTransport]:
"""A handshake-pinned client, for capabilities that exist only in that era."""
assert FASTMCP_GITHUB_TOKEN is not None
return Client(
StreamableHttpTransport(
url=GITHUB_REMOTE_MCP_URL,
auth=BearerAuth(FASTMCP_GITHUB_TOKEN),
),
mode="legacy",
)
class TestGithubMCPRemote:
async def test_connect_disconnect(
self,
@ -44,11 +65,16 @@ class TestGithubMCPRemote:
await streamable_http_client._disconnect() # pylint: disable=W0212 (protected-access)
assert streamable_http_client.is_connected() is False
async def test_ping(self, streamable_http_client: Client[StreamableHttpTransport]):
"""Test pinging the server."""
async with streamable_http_client:
assert streamable_http_client.is_connected() is True
result = await streamable_http_client.ping()
async def test_ping(self, legacy_client: Client[StreamableHttpTransport]):
"""Test pinging the server.
`ping` is defined only in the handshake era the modern protocol version
does not carry the method at all so this pins `mode="legacy"` rather
than relying on the default negotiation landing there.
"""
async with legacy_client:
assert legacy_client.is_connected() is True
result = await legacy_client.ping()
assert result is True
async def test_list_tools(
@ -106,6 +132,10 @@ class TestGithubMCPRemote:
"""Test calling a list_commit tool"""
async with streamable_http_client:
assert streamable_http_client.is_connected()
# On a modern connection the client derives `Mcp-Param-*` headers from
# the tool's schema, which it only holds once the tool has been listed
# in this session. Listing first keeps the call correct in either era.
await streamable_http_client.list_tools()
result = await streamable_http_client.call_tool(
"list_commits", {"owner": "prefecthq", "repo": "fastmcp"}
)

View file

@ -149,6 +149,7 @@ class TestBareSlimImport:
The import must be deferred to the point of actual screening.
"""
@pytest.mark.subprocess_heavy
def test_resources_import_without_sdk(self):
code = textwrap.dedent(
"""

View file

@ -3,6 +3,7 @@
import asyncio
import secrets
import time
from contextlib import suppress
from unittest.mock import Mock
from urllib.parse import urlencode
@ -31,6 +32,7 @@ class MockOAuthProvider:
self.base_url = f"http://localhost:{port}"
self.app = None
self.server = None
self._serve_task: asyncio.Task | None = None
# Storage for OAuth state
self.authorization_codes = {}
@ -235,16 +237,25 @@ class MockOAuthProvider:
self.server = Server(config)
# Start server in background
asyncio.create_task(self.server.serve())
self._serve_task = asyncio.create_task(self.server.serve())
# Wait for server to be ready
await asyncio.sleep(0.05)
# Wait for the server to finish startup instead of a fixed sleep.
# uvicorn.Server flips `started` to True once the listening socket
# is bound, right before it would start accepting connections.
deadline = asyncio.get_event_loop().time() + 5.0
while not self.server.started:
if asyncio.get_event_loop().time() > deadline:
raise RuntimeError("Mock OAuth server failed to start in time")
await asyncio.sleep(0.005)
async def stop(self):
"""Stop the mock OAuth server."""
if self.server:
self.server.should_exit = True
await asyncio.sleep(0.01)
if self._serve_task is not None:
# Wait for the actual shutdown rather than a fixed sleep.
with suppress(TimeoutError):
await asyncio.wait_for(self._serve_task, timeout=5.0)
def reset(self):
"""Reset all state for next test."""

View file

@ -207,6 +207,7 @@ class TestIdentityAssertionConfig:
with pytest.raises(ValueError):
IdentityAssertion(trusted_issuers=[ISSUER], algorithms={ISSUER: "HS256"})
@pytest.mark.subprocess_heavy
def test_lazy_reexport_does_not_import_module(self):
# fastmcp.server.auth must not load identity_assertion (and its
# httpx2 dependency) eagerly — the re-export is lazy via __getattr__.

View file

@ -1,7 +1,6 @@
"""Unit tests for JWT issuer and token encryption."""
import base64
import time
import pytest
from joserfc.errors import JoseError
@ -163,24 +162,26 @@ class TestJWTIssuer:
def test_verify_token_validates_expiration(self, issuer):
"""Test that expired tokens are rejected."""
# Create token that expires in 1 second
token = issuer.issue_access_token(
# A token that is still valid should verify successfully.
valid_token = issuer.issue_access_token(
client_id="client-abc",
scopes=["read"],
jti="token-id",
expires_in=1,
jti="valid-token-id",
)
# Should be valid immediately
payload = issuer.verify_token(token)
payload = issuer.verify_token(valid_token)
assert payload["client_id"] == "client-abc"
# Wait for token to expire
time.sleep(1.1)
# Should be rejected
# A token issued already-expired should be rejected. verify_token()
# does a strict `exp < time.time()` comparison with no clock-skew
# leeway, so this is instant and deterministic (no sleep needed).
expired_token = issuer.issue_access_token(
client_id="client-abc",
scopes=["read"],
jti="expired-token-id",
expires_in=-10,
)
with pytest.raises(JoseError, match="expired"):
issuer.verify_token(token)
issuer.verify_token(expired_token)
def test_verify_token_validates_issuer(self, issuer):
"""Test that tokens from different issuers are rejected."""

View file

@ -4,11 +4,9 @@ import pytest
from mcp_types import TextContent, TextResourceContents
from starlette.requests import Request
from fastmcp.client import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.server.dependencies import CurrentHeaders, CurrentRequest, get_http_request
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_async
from fastmcp.utilities.tests import ASGIServer, asgi_server
def fastmcp_server():
@ -44,25 +42,21 @@ def fastmcp_server():
async def shttp_server():
"""Start a test server with StreamableHttp transport."""
server = fastmcp_server()
async with run_server_async(server, transport="http") as url:
yield url
async with asgi_server(server, transport="http") as running_server:
yield running_server
@pytest.fixture
async def sse_server():
"""Start a test server with SSE transport."""
server = fastmcp_server()
async with run_server_async(server, transport="sse") as url:
yield url
async with asgi_server(server, transport="sse") as running_server:
yield running_server
async def test_http_headers_resource_shttp(shttp_server: str):
async def test_http_headers_resource_shttp(shttp_server: ASGIServer):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-DEMO-HEADER": "ABC"}
)
) as client:
async with shttp_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
@ -70,11 +64,9 @@ async def test_http_headers_resource_shttp(shttp_server: str):
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_resource_sse(sse_server: str):
async def test_http_headers_resource_sse(sse_server: ASGIServer):
"""Test getting HTTP headers from the server."""
async with Client(
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client:
raw_result = await client.read_resource("request://headers")
assert isinstance(raw_result[0], TextResourceContents)
json_result = json.loads(raw_result[0].text)
@ -82,34 +74,24 @@ async def test_http_headers_resource_sse(sse_server: str):
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_tool_shttp(shttp_server: str):
async def test_http_headers_tool_shttp(shttp_server: ASGIServer):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-DEMO-HEADER": "ABC"}
)
) as client:
async with shttp_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client:
result = await client.call_tool("get_headers_tool")
assert "x-demo-header" in result.data
assert result.data["x-demo-header"] == "ABC"
async def test_http_headers_tool_sse(sse_server: str):
async with Client(
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
async def test_http_headers_tool_sse(sse_server: ASGIServer):
async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client:
result = await client.call_tool("get_headers_tool")
assert "x-demo-header" in result.data
assert result.data["x-demo-header"] == "ABC"
async def test_http_headers_prompt_shttp(shttp_server: str):
async def test_http_headers_prompt_shttp(shttp_server: ASGIServer):
"""Test getting HTTP headers from the server."""
async with Client(
transport=StreamableHttpTransport(
shttp_server, headers={"X-DEMO-HEADER": "ABC"}
)
) as client:
async with shttp_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client:
result = await client.get_prompt("get_headers_prompt")
assert isinstance(result.messages[0].content, TextContent)
json_result = json.loads(result.messages[0].content.text)
@ -117,11 +99,9 @@ async def test_http_headers_prompt_shttp(shttp_server: str):
assert json_result["x-demo-header"] == "ABC"
async def test_http_headers_prompt_sse(sse_server: str):
async def test_http_headers_prompt_sse(sse_server: ASGIServer):
"""Test getting HTTP headers from the server."""
async with Client(
transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"})
) as client:
async with sse_server.client(headers={"X-DEMO-HEADER": "ABC"}) as client:
result = await client.get_prompt("get_headers_prompt")
assert isinstance(result.messages[0].content, TextContent)
json_result = json.loads(result.messages[0].content.text)
@ -129,7 +109,7 @@ async def test_http_headers_prompt_sse(sse_server: str):
assert json_result["x-demo-header"] == "ABC"
async def test_get_http_headers_excludes_content_type(sse_server: str):
async def test_get_http_headers_excludes_content_type(sse_server: ASGIServer):
"""Test that get_http_headers() excludes content-type header (issue #3097).
This prevents HTTP 415 errors when forwarding headers to downstream APIs
@ -144,16 +124,13 @@ async def test_get_http_headers_excludes_content_type(sse_server: str):
"""Check that problematic headers are excluded from get_http_headers()."""
return get_http_headers()
async with run_server_async(server, transport="sse") as url:
async with Client(
transport=SSETransport(
url,
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"X-Custom-Header": "should-be-included",
},
)
async with asgi_server(server, transport="sse") as running_server:
async with running_server.client(
headers={
"Content-Type": "application/json",
"Accept": "application/json",
"X-Custom-Header": "should-be-included",
}
) as client:
result = await client.call_tool("check_excluded_headers")
headers = result.data
@ -178,9 +155,9 @@ async def test_background_task_can_read_snapshotted_request_headers():
request = get_http_request()
return request.headers.get("x-tenant-id", "missing")
async with run_server_async(server, transport="sse") as url:
async with Client(
transport=SSETransport(url, headers={"X-Tenant-ID": "tenant-123"})
async with asgi_server(server, transport="sse") as running_server:
async with running_server.client(
headers={"X-Tenant-ID": "tenant-123"}
) as client:
task = await client.call_tool("check_request_header", task=True)
result = await task.result()
@ -201,15 +178,12 @@ async def test_background_task_current_http_dependencies_restore_headers():
"tenant": request.headers.get("x-tenant-id", "missing"),
}
async with run_server_async(server, transport="sse") as url:
async with Client(
transport=SSETransport(
url,
headers={
"Authorization": "Bearer tenant-token",
"X-Tenant-ID": "tenant-456",
},
)
async with asgi_server(server, transport="sse") as running_server:
async with running_server.client(
headers={
"Authorization": "Bearer tenant-token",
"X-Tenant-ID": "tenant-456",
}
) as client:
task = await client.call_tool("check_headers", task=True)
result = await task.result()

View file

@ -43,7 +43,7 @@ def test_idle_session_is_terminated_after_timeout():
app = create_streamable_http_app(
server=server,
streamable_http_path="/mcp",
session_idle_timeout=0.2,
session_idle_timeout=0.1,
)
with TestClient(app, base_url="http://127.0.0.1") as client:
@ -58,11 +58,15 @@ def test_idle_session_is_terminated_after_timeout():
# Wait past the idle deadline; the SDK's idle cancel scope fires and
# removes the session from the active instances. Poll to stay fast.
# The idle timeout itself is driven by anyio's event-loop clock
# inside the SDK (not a mockable Python-level time source), so this
# remains a real wait; the timeout and poll interval are kept as
# small as reliably possible.
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
if session_id not in sm._server_instances:
break
time.sleep(0.05)
time.sleep(0.02)
assert session_id not in sm._server_instances

View file

@ -381,9 +381,8 @@ async def test_state_isolation_between_streamable_http_clients():
Each client should have its own session ID and isolated state.
"""
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.context import Context
from fastmcp.utilities.tests import run_server_async
from fastmcp.utilities.tests import asgi_server
server = FastMCP("TestServer")
@ -398,12 +397,12 @@ async def test_state_isolation_between_streamable_http_clients():
"session_id": ctx.session_id,
}
async with run_server_async(server, transport="streamable-http") as url:
async with asgi_server(server, transport="streamable-http") as running_server:
import json
# Client 1 stores its value
transport1 = StreamableHttpTransport(url=url)
async with Client(transport=transport1, mode="legacy") as client1:
# Session ids belong to the handshake era, so these pin the legacy era.
async with running_server.client(mode="legacy") as client1:
result1 = await client1.call_tool(
"store_and_read", {"value": "client1-value"}
)
@ -413,8 +412,7 @@ async def test_state_isolation_between_streamable_http_clients():
session_id_1 = data1["session_id"]
# Client 2 should have completely isolated state
transport2 = StreamableHttpTransport(url=url)
async with Client(transport=transport2, mode="legacy") as client2:
async with running_server.client(mode="legacy") as client2:
result2 = await client2.call_tool(
"store_and_read", {"value": "client2-value"}
)

View file

@ -60,8 +60,14 @@ class TestTokenBucketRateLimiter:
# Should fail to consume more
assert await limiter.consume(1) is False
async def test_refill(self):
async def test_refill(self, monkeypatch):
"""Test token refill over time."""
current_time = 0.0
monkeypatch.setattr(
"fastmcp.server.middleware.rate_limiting.time.time",
lambda: current_time,
)
limiter = TokenBucketRateLimiter(
capacity=10, refill_rate=10.0
) # 10 tokens per second
@ -70,11 +76,13 @@ class TestTokenBucketRateLimiter:
assert await limiter.consume(10) is True
assert await limiter.consume(1) is False
# Wait for refill (0.2 seconds = 2 tokens at 10/sec)
await asyncio.sleep(0.2)
# Advance the clock instead of sleeping in real time. Use a small
# base time and a slight margin over the strict 0.2s/2-token
# threshold so the result isn't sensitive to float rounding.
current_time += 0.25
assert await limiter.consume(2) is True
async def test_denied_consumes_do_not_freeze_clock(self):
async def test_denied_consumes_do_not_freeze_clock(self, monkeypatch):
"""Regression for #4056: a client that retries quickly after being
denied must not be able to bypass the configured refill rate.
@ -82,21 +90,26 @@ class TestTokenBucketRateLimiter:
If it only advanced on success, the elapsed window would be re-counted
on each retry, letting a client refill faster than `refill_rate`.
"""
current_time = 0.0
monkeypatch.setattr(
"fastmcp.server.middleware.rate_limiting.time.time",
lambda: current_time,
)
limiter = TokenBucketRateLimiter(capacity=10, refill_rate=10.0)
# Drain the bucket.
assert await limiter.consume(10) is True
# Hammer with denied requests over ~0.2s. With the correct
# implementation, last_refill advances on each call, so total
# accumulated tokens after 0.2s is ~2 (10/s * 0.2s).
# Hammer with denied requests over a simulated ~0.2s (no real sleep).
# With the correct implementation, last_refill advances on each
# call, so total accumulated tokens after 0.2s is ~2 (10/s * 0.2s).
for _ in range(20):
await limiter.consume(1)
await asyncio.sleep(0.01)
current_time += 0.01
# We should NOT be able to consume more than the configured rate
# would allow over the elapsed window. Allow a small slack for
# timing jitter, but stay well below `capacity`.
# would allow over the elapsed window, well below `capacity`.
assert await limiter.consume(5) is False, (
"denied retries should not silently accrue extra tokens"
)
@ -132,8 +145,14 @@ class TestSlidingWindowRateLimiter:
# Should reject over limit
assert await limiter.is_allowed() is False
async def test_sliding_window(self):
async def test_sliding_window(self, monkeypatch):
"""Test sliding window behavior."""
current_time = 0.0
monkeypatch.setattr(
"fastmcp.server.middleware.rate_limiting.time.time",
lambda: current_time,
)
limiter = SlidingWindowRateLimiter(max_requests=2, window_seconds=1)
# Use up requests
@ -141,8 +160,8 @@ class TestSlidingWindowRateLimiter:
assert await limiter.is_allowed() is True
assert await limiter.is_allowed() is False
# Wait for window to pass
await asyncio.sleep(1.1)
# Advance the clock past the window instead of sleeping in real time
current_time += 1.1
# Should be able to make requests again
assert await limiter.is_allowed() is True
@ -528,12 +547,11 @@ class TestRateLimitingMiddlewareIntegration:
async def test_rate_limiting_recovery_over_time(self, rate_limit_server):
"""Test that rate limiting allows requests again after time passes."""
rate_limit_server.add_middleware(
RateLimitingMiddleware(
max_requests_per_second=10.0, # 10 per second = 1 every 100ms
burst_capacity=4,
)
middleware = RateLimitingMiddleware(
max_requests_per_second=10.0, # 10 per second = 1 every 100ms
burst_capacity=4,
)
rate_limit_server.add_middleware(middleware)
async with Client(rate_limit_server) as client:
# Exhaust the burst; the exact number of internal requests before the
@ -547,8 +565,11 @@ class TestRateLimitingMiddlewareIntegration:
break
assert hit_limit, "Rate limit was never triggered"
# Wait for token bucket to refill (150ms should be enough for ~1.5 tokens)
await asyncio.sleep(0.15)
# Simulate token refill without a real sleep: rewind each
# bucket's last-refill timestamp so the next consume() sees
# ~150ms of elapsed time (10 tokens/sec => ~1.5 tokens refilled).
for limiter in middleware.limiters.values():
limiter.last_refill -= 0.15
# Should be able to make another request
result = await client.call_tool("quick_action", {"message": "after_wait"})

View file

@ -26,7 +26,7 @@ async def test_concurrent_foreground_tools_with_context():
@mcp.tool()
async def slow_tool(name: str, ctx: Context) -> str:
await asyncio.sleep(0.05)
await asyncio.sleep(0.01)
results.append(name)
return f"done:{name}"
@ -77,7 +77,7 @@ async def test_concurrent_background_tasks_with_context():
@mcp.tool(task=True)
async def bg_tool(name: str, ctx: Context) -> str:
await asyncio.sleep(0.05)
await asyncio.sleep(0.01)
return f"bg:{name}"
async with Client(mcp, mode="legacy") as client:

View file

@ -6,6 +6,7 @@ No mocking of Redis, sessions, or Docket internals.
"""
import asyncio
import time
import mcp_types
@ -124,8 +125,7 @@ class TestNotificationIntegration:
# After client disconnects, subscriber should be cleaned up
# Allow brief time for async cleanup
for _ in range(20):
if get_subscriber_count() == count_before:
break
await asyncio.sleep(0.05)
deadline = time.monotonic() + 1.0
while get_subscriber_count() != count_before and time.monotonic() < deadline:
await asyncio.sleep(0.005)
assert get_subscriber_count() == count_before

View file

@ -77,9 +77,10 @@ async def test_progress_status_message_in_background_task():
await progress.increment()
step_started.set()
# Give test time to poll status
await asyncio.sleep(0.2)
# No settling wait needed: the server never clears the progress
# message on completion (only a failure overwrites it), so whatever
# "Step N of 3" message is current when the test polls status()
# below still satisfies the assertion, win or lose the race.
await progress.set_message("Step 2 of 3")
await progress.increment()
await progress.set_message("Step 3 of 3")

View file

@ -5,6 +5,7 @@ Tests the tasks/get, tasks/result, and tasks/list JSON-RPC protocol methods.
"""
import asyncio
import time
import pytest
from mcp.shared.exceptions import MCPError
@ -30,8 +31,12 @@ async def endpoint_server():
@mcp.tool(task=True) # Enable background execution
async def slow_tool() -> str:
"""A slow tool for testing cancellation."""
await asyncio.sleep(10)
"""A slow tool for testing cancellation.
Never completes on its own - the only test that submits this task
cancels it well before any real-time completion would matter.
"""
await asyncio.Event().wait()
return "done"
return mcp
@ -160,17 +165,24 @@ async def test_task_cancellation_workflow(endpoint_server):
# Submit slow task
task = await client.call_tool("slow_tool", {}, task=True)
# Give it a moment to start
await asyncio.sleep(0.1)
# Wait until the task is tracked as working before cancelling
deadline = time.monotonic() + 5.0
status = await task.status()
while status.status != "working" and time.monotonic() < deadline:
await asyncio.sleep(0.005)
status = await task.status()
# Cancel the task
await task.cancel()
# Give cancellation a moment to process
await asyncio.sleep(0.1)
# Poll until cancellation is reflected in task status
deadline = time.monotonic() + 5.0
status = await task.status()
while status.status != "cancelled" and time.monotonic() < deadline:
await asyncio.sleep(0.005)
status = await task.status()
# Task should be in cancelled state
status = await task.status()
assert status.status == "cancelled"
@ -192,7 +204,11 @@ async def test_task_cancellation_interrupts_running_coroutine(endpoint_server):
async def interruptible_tool() -> str:
started.set()
try:
await asyncio.sleep(60)
# Never completes on its own - the test cancels this task well
# before any real-time completion would matter, so a genuinely
# suspended coroutine (rather than a fixed-duration sleep) is
# enough to prove cancellation delivers CancelledError.
await asyncio.Event().wait()
completed_normally.set()
return "completed"
except asyncio.CancelledError:

View file

@ -6,6 +6,7 @@ on mounted child servers through a parent server.
"""
import asyncio
import time
import mcp_types as mt
import pytest
@ -140,7 +141,7 @@ class TestMountedToolTasks:
"""Can poll task status for mounted tool."""
async with Client(parent_server, mode="legacy") as client:
task = await client.call_tool(
"child_slow_child_tool", {"duration": 0.5}, task=True
"child_slow_child_tool", {"duration": 0.05}, task=True
)
# Check status while running
@ -162,15 +163,28 @@ class TestMountedToolTasks:
"child_slow_child_tool", {"duration": 10.0}, task=True
)
# Let it start
await asyncio.sleep(0.1)
# Wait until the task is tracked as working before cancelling it
deadline = time.monotonic() + 5.0
status = await task.status()
while status.status != "working" and time.monotonic() < deadline:
await asyncio.sleep(0.005)
status = await task.status()
# Cancel the task
await task.cancel()
# Check status
# Cancellation propagation isn't instantaneous, so poll for the
# terminal state rather than asserting immediately after cancel().
deadline = time.monotonic() + 5.0
status = await task.status()
assert status.status == "cancelled"
while status.status != "cancelled" and time.monotonic() < deadline:
await asyncio.sleep(0.005)
status = await task.status()
assert status.status == "cancelled", (
f"task did not reach 'cancelled' within 5s of cancel() "
f"(last status: {status.status!r})"
)
async def test_graceful_degradation_sync_mounted_tool(self, parent_server):
"""Sync-only mounted tool returns error with task=True."""

View file

@ -28,9 +28,14 @@ async def notification_server():
return value * 2
@mcp.tool(task=True)
async def slow_task(duration: float = 0.1) -> str:
"""Slow task for testing working status."""
await asyncio.sleep(duration)
async def slow_task() -> str:
"""Task that never completes on its own.
Only used to verify disconnect-while-running doesn't crash - the
test disconnects before the task would finish, so it never needs
to actually complete.
"""
await asyncio.Event().wait()
return "completed"
@mcp.tool(task=True)
@ -41,13 +46,11 @@ async def notification_server():
@mcp.prompt(task=True)
async def test_prompt(name: str) -> str:
"""Test prompt for background execution."""
await asyncio.sleep(0.05)
return f"Hello, {name}!"
@mcp.resource("test://resource", task=True)
async def test_resource() -> str:
"""Test resource for background execution."""
await asyncio.sleep(0.05)
return "resource content"
return mcp
@ -84,9 +87,9 @@ async def test_subscription_handles_task_completion(notification_server: FastMCP
assert result2.data == 4
assert result3.data == 6
# Subscriptions should all clean up
# Give them a moment
await asyncio.sleep(0.1)
# Subscriptions clean up deterministically via the connection's
# exit stack when the client disconnects (see test below), so no
# settling wait is needed here.
async def test_subscription_handles_task_failure(notification_server: FastMCP):
@ -98,8 +101,8 @@ async def test_subscription_handles_task_failure(notification_server: FastMCP):
with pytest.raises(Exception):
await task
# Subscription should handle failure and clean up
await asyncio.sleep(0.1)
# Subscription cleans up deterministically via the connection's
# exit stack on disconnect; no settling wait is needed here.
async def test_subscription_for_prompt_tasks(notification_server: FastMCP):
@ -111,8 +114,8 @@ async def test_subscription_for_prompt_tasks(notification_server: FastMCP):
# Prompt result has messages
assert result
# Subscription should clean up
await asyncio.sleep(0.1)
# Subscription cleans up deterministically via the connection's
# exit stack on disconnect; no settling wait is needed here.
async def test_subscription_for_resource_tasks(notification_server: FastMCP):
@ -123,8 +126,8 @@ async def test_subscription_for_resource_tasks(notification_server: FastMCP):
result = await task
assert result # Resource contents
# Subscription should clean up
await asyncio.sleep(0.1)
# Subscription cleans up deterministically via the connection's
# exit stack on disconnect; no settling wait is needed here.
async def test_subscriptions_cleanup_on_session_disconnect(
@ -132,8 +135,9 @@ async def test_subscriptions_cleanup_on_session_disconnect(
):
"""Subscriptions are cleaned up when session disconnects."""
# Start session and create task
# Task submission is a handshake-era capability, so this pins the legacy era.
async with Client(notification_server, mode="legacy") as client:
task = await client.call_tool("slow_task", {"duration": 1.0}, task=True)
task = await client.call_tool("slow_task", {}, task=True)
task_id = task.task_id
# Disconnect before task completes (session __aexit__ cancels subscriptions)
@ -156,5 +160,5 @@ async def test_multiple_concurrent_subscriptions(notification_server: FastMCP):
results = await asyncio.gather(*tasks)
assert len(results) == 10
# All subscriptions should clean up
await asyncio.sleep(0.1)
# All subscriptions clean up deterministically via the connection's
# exit stack on disconnect; no settling wait is needed here.

View file

@ -24,7 +24,10 @@ async def keepalive_server():
@mcp.tool(task=True)
async def slow_task() -> str:
await asyncio.sleep(1)
# Never completes during the test - the only test that submits this
# task checks status immediately after submission and never awaits
# completion, so there's no need for a real-time sleep here.
await asyncio.Event().wait()
return "done"
return mcp

View file

@ -498,9 +498,7 @@ class TestTransportIntegration:
async def test_transport_set_via_http_middleware(self):
"""Test that transport is set per-request via HTTP middleware."""
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
from fastmcp.utilities.tests import asgi_client
mcp = FastMCP("test")
observed_transport = None
@ -511,9 +509,7 @@ class TestTransportIntegration:
observed_transport = ctx.transport
return observed_transport or "none"
async with run_server_async(mcp, transport="streamable-http") as url:
transport = StreamableHttpTransport(url=url)
async with Client(transport=transport) as client:
result = await client.call_tool("get_transport", {})
assert observed_transport == "streamable-http"
assert result.data == "streamable-http"
async with asgi_client(mcp, transport="streamable-http") as client:
result = await client.call_tool("get_transport", {})
assert observed_transport == "streamable-http"
assert result.data == "streamable-http"

View file

@ -41,17 +41,24 @@ from fastmcp.mcp_config import (
from fastmcp.server.elicitation import AcceptedElicitation
from fastmcp.tools.base import Tool as FastMCPTool
# These tests spawn subprocess servers via stdio which can be slow under
# parallel CI load. Give them more headroom than the 5s default, and skip
# entirely on Windows due to process lifecycle issues.
# Some tests in this module spawn subprocess servers via stdio, each paying a
# full interpreter startup plus `import fastmcp` (~0.7s). They take 3-6s idle,
# but on a loaded CI runner with four xdist workers competing they have blown a
# 15s ceiling. The timeout is here to catch a genuine hang, not to police speed,
# so give the module room rather than tuning each test individually.
pytestmark = [
pytest.mark.timeout(15),
pytest.mark.skipif(
sys.platform.startswith("win32"),
reason="Windows has process lifecycle issues with stdio subprocesses",
),
pytest.mark.timeout(60),
]
# Most tests below run entirely in-memory (via InMemoryStdioMCPServer) or
# only parse/serialize config objects, so they're safe on Windows. Apply this
# marker only to tests that spawn a real subprocess (or attempt to, e.g. via
# a nonexistent command) — those still hit Windows process lifecycle issues.
requires_subprocess = pytest.mark.skipif(
sys.platform.startswith("win32"),
reason="Windows has process lifecycle issues with stdio subprocesses",
)
def running_under_debugger():
return os.environ.get("DEBUGPY_RUNNING") == "true"
@ -442,12 +449,13 @@ async def _wait_for_process_exit(pid: int, timeout: float = 3.0) -> None:
psutil.Process(pid)
except psutil.NoSuchProcess:
return
await asyncio.sleep(0.05)
await asyncio.sleep(0.005)
# Final check — if still alive, let the NoSuchProcess propagation fail the test clearly
psutil.Process(pid)
pytest.fail(f"Process {pid} still alive after {timeout}s")
@requires_subprocess
@pytest.mark.skipif(
running_under_debugger(),
reason="Debugger holds a reference to the transport",
@ -508,6 +516,7 @@ async def test_multi_client_lifespan(tmp_path: Path):
await _wait_for_process_exit(pid_2)
@requires_subprocess
@pytest.mark.timeout(15)
async def test_multi_client_force_close(tmp_path: Path):
server_script = inspect.cleandoc("""
@ -666,6 +675,7 @@ async def test_multi_client_with_logging(caplog):
assert test_records[0].msg == "test 42"
@requires_subprocess
async def test_multi_client_with_transforms(tmp_path: Path):
"""
Tests that transforms are properly applied to the tools.
@ -722,6 +732,7 @@ async def test_multi_client_with_transforms(tmp_path: Path):
assert result.data == 3
@requires_subprocess
async def test_canonical_multi_client_with_transforms(tmp_path: Path):
"""Test that transforms are not applied to servers in a canonical MCPConfig."""
server_script = inspect.cleandoc("""
@ -773,6 +784,7 @@ async def test_canonical_multi_client_with_transforms(tmp_path: Path):
assert "test_1_transformed_add" not in tools_by_name
@requires_subprocess
@pytest.mark.flaky(retries=3)
async def test_multi_client_transform_with_filtering(tmp_path: Path):
"""
@ -835,6 +847,7 @@ async def test_multi_client_transform_with_filtering(tmp_path: Path):
assert "test_2_subtract" in tools_by_name
@requires_subprocess
@pytest.mark.flaky(retries=3)
async def test_single_server_config_include_tags_filtering(tmp_path: Path):
"""include_tags should filter tools even with a single server in the config."""
@ -1053,6 +1066,7 @@ async def test_single_server_config_transport():
assert len(transport._transports) == 1
@requires_subprocess
@pytest.mark.parametrize(
"server_order",
[
@ -1081,6 +1095,7 @@ async def test_multi_server_partial_failure(server_order: dict):
assert len(tools) == 1
@requires_subprocess
async def test_multi_server_partial_failure_logs_warning(caplog):
"""A warning should be logged when a server fails to connect."""
config = MCPConfig(
@ -1105,6 +1120,7 @@ async def test_multi_server_partial_failure_logs_warning(caplog):
assert len(warning_records) == 1
@requires_subprocess
async def test_multi_server_all_fail():
"""When all servers fail to connect, a ConnectionError should be raised."""
config = MCPConfig(
@ -1136,6 +1152,7 @@ def _make_ping_server() -> FastMCP:
return app
@requires_subprocess
async def test_multi_server_partial_failure_cleanup():
"""Transports for failed servers should not leak into _transports."""
config = MCPConfig(

View file

@ -17,6 +17,8 @@ import subprocess
import sys
import textwrap
import pytest
_BLOCKER_SCRIPT = textwrap.dedent(
"""
import sys
@ -44,6 +46,7 @@ _BLOCKER_SCRIPT = textwrap.dedent(
)
@pytest.mark.subprocess_heavy
def test_fastmcp_imports_without_legacy_httpx():
result = subprocess.run(
[sys.executable, "-c", _BLOCKER_SCRIPT],

View file

@ -29,6 +29,7 @@ from fastmcp.tools.function_tool import DecoratedTool, FunctionTool, ToolMeta
"from fastmcp.server import Context, FastMCP, create_proxy",
],
)
@pytest.mark.subprocess_heavy
def test_component_import_works_in_fresh_interpreter(statement: str):
result = subprocess.run(
[sys.executable, "-c", statement],

View file

@ -142,7 +142,7 @@ class TestRunInThread:
def blocking() -> str:
import time
time.sleep(0.2)
time.sleep(0.05)
return "done"
ticks = 0
@ -163,8 +163,10 @@ class TestRunInThread:
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "done"
# With inline execution, ticks should be near zero — the 200ms sleep
# blocks the loop. Under a thread pool (default), ticks would be ~10.
# With inline execution, ticks should be near zero — the blocking
# sleep never yields control, so at most one already-scheduled timer
# fires once control returns. Under a thread pool (default), ticks
# would scale with sleep duration instead.
assert ticks <= 2
async def test_default_threadpool_permits_concurrency(self):
@ -196,7 +198,11 @@ class TestRunInThread:
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "done"
assert ticks >= 5
# Ideal is 0.2s / 0.02s = 10 ticks. We only require 3 (30% of ideal)
# to tolerate a loaded/slow runner, while staying well clear of the
# blocked case's `ticks <= 2` bound above so the two tests can never
# produce overlapping, ambiguous results.
assert ticks >= 3
class TestRunInThreadViaStandaloneDecorator:

View file

@ -46,7 +46,7 @@ class TestToolTimeout:
@mcp.tool(timeout=5.0)
async def fast_async_tool() -> str:
await anyio.sleep(0.1)
await anyio.sleep(0.01)
return "completed"
result = await mcp.call_tool("fast_async_tool")
@ -59,7 +59,7 @@ class TestToolTimeout:
@mcp.tool(timeout=5.0)
def fast_sync_tool() -> str:
time.sleep(0.1)
time.sleep(0.01)
return "completed"
result = await mcp.call_tool("fast_sync_tool")
@ -72,7 +72,7 @@ class TestToolTimeout:
@mcp.tool(timeout=0.2)
async def slow_async_tool() -> str:
await anyio.sleep(2.0)
await anyio.sleep(0.6)
return "should not reach"
# TimeoutError is caught and converted to ToolError by FastMCP
@ -97,7 +97,7 @@ class TestToolTimeout:
@mcp.tool(timeout=0.1)
async def slow_tool() -> str:
await anyio.sleep(1.0)
await anyio.sleep(0.3)
return "never"
# Verify that ToolError is raised (timeout warning is logged to stderr)
@ -109,7 +109,7 @@ class TestToolTimeout:
from fastmcp.tools import Tool
async def my_slow_tool() -> str:
await anyio.sleep(1.0)
await anyio.sleep(0.3)
return "never"
tool = Tool.from_function(my_slow_tool, timeout=0.1)
@ -137,7 +137,7 @@ class TestToolTimeout:
@mcp.tool(task=True, timeout=1.0)
async def task_with_timeout() -> str:
await anyio.sleep(0.1)
await anyio.sleep(0.01)
return "completed"
# Tool should be registered successfully
@ -153,17 +153,17 @@ class TestToolTimeout:
@mcp.tool(timeout=1.0)
async def short_timeout() -> str:
await anyio.sleep(0.1)
await anyio.sleep(0.01)
return "short"
@mcp.tool(timeout=5.0)
async def long_timeout() -> str:
await anyio.sleep(0.1)
await anyio.sleep(0.01)
return "long"
@mcp.tool
async def no_timeout() -> str:
await anyio.sleep(0.1)
await anyio.sleep(0.01)
return "none"
# All should complete successfully
@ -184,7 +184,7 @@ class TestToolTimeout:
@mcp.tool(timeout=0.1)
async def times_out() -> str:
await anyio.sleep(1.0)
await anyio.sleep(0.3)
return "never"
# TimeoutError should be caught and converted to ToolError

View file

@ -41,6 +41,23 @@ def test_tool_from_tool_no_change(add_tool):
assert new_tool.description == add_tool.description
def test_transformed_tool_required_order_is_deterministic():
"""`required` must follow property order, not set iteration order.
Set iteration order varies with PYTHONHASHSEED, which broke snapshot
tests of tools/list output across processes.
"""
def fn(alpha: int, beta: str, gamma: float, delta: bool, epsilon: int) -> str:
return "x"
base = Tool.from_function(fn)
transformed = Tool.from_tool(base, transform_args={"alpha": ArgTransform(name="a")})
props = list(transformed.parameters["properties"])
assert transformed.parameters["required"] == props
assert props == ["a", "beta", "gamma", "delta", "epsilon"]
def test_from_tool_accepts_decorated_function():
@tool
def search(q: str, limit: int = 10) -> list[str]:

View file

@ -0,0 +1,255 @@
"""Tests for the in-process ASGI bridge and `asgi_server`."""
from typing import Literal
import httpx2
import pytest
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response, StreamingResponse
from starlette.routing import Route
from starlette.types import Receive, Scope, Send
from fastmcp import Context, FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair
from fastmcp.utilities.asgi_transport import StreamingASGITransport, run_asgi_lifespan
from fastmcp.utilities.tests import ASGIServer, asgi_server
def build_server() -> FastMCP:
server = FastMCP("BridgeTestServer")
@server.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
@server.tool
async def elicit_name(ctx: Context) -> str:
"""Round-trips a server-initiated request while the response is still open."""
result = await ctx.elicit("What is your name?", response_type=str)
if result.action == "accept":
return f"You said {result.data}"
return "declined"
return server
class TestStreamingASGITransport:
async def test_forwards_chunks_as_the_app_produces_them(self):
"""The bridge must stream, not buffer: chunks arrive before the app finishes."""
async def stream(request: Request) -> StreamingResponse:
async def body():
yield b"first"
yield b"second"
return StreamingResponse(body(), media_type="text/plain")
app = Starlette(routes=[Route("/stream", stream)])
async with httpx2.AsyncClient(
transport=StreamingASGITransport(app), base_url="http://testserver"
) as client:
chunks: list[bytes] = []
async with client.stream("GET", "/stream") as response:
async for chunk in response.aiter_bytes():
chunks.append(chunk)
assert b"".join(chunks) == b"firstsecond"
async def test_request_body_and_headers_reach_the_app(self):
async def echo(request: Request) -> Response:
body = await request.body()
return Response(
content=body,
headers={"x-seen-header": request.headers.get("x-demo", "missing")},
)
app = Starlette(routes=[Route("/echo", echo, methods=["POST"])])
async with httpx2.AsyncClient(
transport=StreamingASGITransport(app), base_url="http://testserver"
) as client:
response = await client.post(
"/echo", content=b"payload", headers={"x-demo": "abc"}
)
assert response.content == b"payload"
assert response.headers["x-seen-header"] == "abc"
async def test_query_string_reaches_the_app(self):
async def show(request: Request) -> Response:
return Response(content=request.query_params["q"])
app = Starlette(routes=[Route("/search", show)])
async with httpx2.AsyncClient(
transport=StreamingASGITransport(app), base_url="http://testserver"
) as client:
response = await client.get("/search", params={"q": "hello"})
assert response.text == "hello"
async def test_error_before_response_start_propagates_to_caller(self):
async def boom(scope: Scope, receive: Receive, send: Send) -> None:
raise ValueError("app exploded")
async with httpx2.AsyncClient(
transport=StreamingASGITransport(boom), base_url="http://testserver"
) as client:
with pytest.raises(ValueError, match="app exploded"):
await client.get("/anything")
async def test_error_after_response_start_truncates_the_body(self):
"""Post-start failures look like a dropped socket, not a raised exception."""
async def boom(scope: Scope, receive: Receive, send: Send) -> None:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/plain")],
}
)
# Raise with no checkpoint in between, so the error is guaranteed to be
# recorded before the transport's waiter resumes — the scheduling order
# that used to surface the failure as a raised exception.
raise ValueError("app exploded mid-response")
async with httpx2.AsyncClient(
transport=StreamingASGITransport(boom), base_url="http://testserver"
) as client:
response = await client.get("/anything")
assert response.status_code == 200
assert response.content == b""
class TestRunAsgiLifespan:
async def test_startup_and_shutdown_run_once(self):
events: list[str] = []
async def app(scope: Scope, receive: Receive, send: Send) -> None:
assert scope["type"] == "lifespan"
while True:
message = await receive()
if message["type"] == "lifespan.startup":
events.append("startup")
await send({"type": "lifespan.startup.complete"})
elif message["type"] == "lifespan.shutdown":
events.append("shutdown")
await send({"type": "lifespan.shutdown.complete"})
return
async with run_asgi_lifespan(app):
assert events == ["startup"]
assert events == ["startup", "shutdown"]
async def test_startup_failure_raises(self):
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await receive()
await send({"type": "lifespan.startup.failed", "message": "nope"})
with pytest.raises(RuntimeError, match="startup failed"):
async with run_asgi_lifespan(app):
pass
async def test_shutdown_failure_raises(self):
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await receive()
await send({"type": "lifespan.startup.complete"})
await receive()
await send({"type": "lifespan.shutdown.failed", "message": "teardown nope"})
with pytest.raises(RuntimeError, match="shutdown failed"):
async with run_asgi_lifespan(app):
pass
async def test_shutdown_failure_does_not_mask_body_error(self):
"""A broken teardown must never hide the failure the caller actually cares about."""
async def app(scope: Scope, receive: Receive, send: Send) -> None:
await receive()
await send({"type": "lifespan.startup.complete"})
await receive()
await send({"type": "lifespan.shutdown.failed", "message": "teardown nope"})
with pytest.raises(ValueError, match="body exploded"):
async with run_asgi_lifespan(app):
raise ValueError("body exploded")
class TestRunServerInMemory:
@pytest.mark.parametrize("transport", ["http", "streamable-http", "sse"])
async def test_client_round_trip(
self, transport: Literal["http", "streamable-http", "sse"]
):
async with asgi_server(build_server(), transport=transport) as server:
async with server.client() as client:
result = await client.call_tool("greet", {"name": "World"})
assert result.data == "Hello, World!"
async def test_default_path_matches_transport(self):
async with asgi_server(build_server(), transport="sse") as server:
assert server.url.endswith("/sse")
async with asgi_server(build_server(), transport="http") as server:
assert server.url.endswith("/mcp")
async def test_custom_path_is_used(self):
async with asgi_server(build_server(), path="/custom") as server:
assert server.url.endswith("/custom")
# `ping` exists only in the handshake era, so this pins that era.
async with server.client(mode="legacy") as client:
assert await client.ping() is True
async def test_server_initiated_request_mid_stream(self):
"""Elicitation needs a server->client request while the POST is still open.
This is the capability a buffering ASGI transport cannot provide, and the
reason the bridge streams responses.
"""
async def elicitation_handler(message, response_type, params, ctx):
return {"value": "Alice"}
async with asgi_server(build_server()) as server:
# Server-initiated elicitation is handshake-era only, and it is the
# mid-stream request this test exists to exercise, so pin that era.
async with server.client(
elicitation_handler=elicitation_handler, mode="legacy"
) as client:
result = await client.call_tool("elicit_name", {})
assert result.data == "You said Alice"
async def test_http_client_reaches_the_app(self):
async with asgi_server(build_server()) as server:
async with server.http_client() as http:
# A GET on the streamable HTTP endpoint without a session is rejected;
# the point is that the request reaches the real app at all.
response = await http.get(server.url)
assert response.status_code in {400, 405}
async def test_auth_middleware_runs(self):
"""A migrated test must not pass by bypassing the real middleware stack."""
key_pair = RSAKeyPair.generate()
server = build_server()
server.auth = JWTVerifier(
public_key=key_pair.public_key,
issuer="https://issuer.example.com",
audience="test-audience",
)
async with asgi_server(server) as running_server:
async with running_server.http_client() as http:
unauthenticated = await http.post(
running_server.url,
json={"jsonrpc": "2.0", "id": 1, "method": "initialize"},
)
assert unauthenticated.status_code == 401
async def test_yields_in_memory_server(self):
async with asgi_server(build_server()) as server:
assert isinstance(server, ASGIServer)
assert server.transport_type == "http"

View file

@ -1,7 +1,11 @@
"""Tests for fastmcp.utilities.async_utils."""
import functools
import inspect
from collections.abc import Awaitable, Iterator
from typing import Any
import anyio
import pytest
from exceptiongroup import BaseExceptionGroup
@ -9,6 +13,7 @@ from fastmcp import Client, FastMCP
from fastmcp.prompts import prompt
from fastmcp.resources import resource
from fastmcp.tools import tool
from fastmcp.utilities import async_utils
from fastmcp.utilities.async_utils import gather, is_coroutine_function
@ -55,14 +60,20 @@ class TestGather:
async def value(result: int) -> int:
return result
assert await gather(value(1), value(2), value(3)) == [1, 2, 3]
assert await gather([value(1), value(2), value(3)]) == [1, 2, 3]
async def test_accepts_a_generator(self) -> None:
async def value(result: int) -> int:
return result
assert await gather(value(i) for i in [1, 2, 3]) == [1, 2, 3]
async def test_raises_by_default(self) -> None:
async def fail() -> int:
raise RuntimeError("boom")
with pytest.raises(BaseExceptionGroup) as exc_info:
await gather(fail())
await gather([fail()])
assert len(exc_info.value.exceptions) == 1
assert isinstance(exc_info.value.exceptions[0], RuntimeError)
@ -74,11 +85,118 @@ class TestGather:
async def value() -> int:
return 1
result = await gather(fail(), value(), return_exceptions=True)
result = await gather([fail(), value()], return_exceptions=True)
assert isinstance(result[0], ValueError)
assert result[1] == 1
async def test_does_not_leak_coroutine_when_scheduling_is_interrupted(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If handing an already-created awaitable off to the task group
raises partway through scheduling, that awaitable must be closed
rather than silently garbage collected later - which is what
produces a "coroutine was never awaited" RuntimeWarning attributed
to whatever unrelated code happens to be running when the garbage
collector eventually reclaims it.
In production this can happen when a synchronous signal handler
(e.g. pytest-timeout's SIGALRM-based per-test timeout) fires inside
anyio's task-spawning internals. This test reproduces the same
shape of interruption deterministically by making the task group's
``start_soon`` raise partway through scheduling, instead of relying
on real signal timing.
"""
real_create_task_group = anyio.create_task_group
class _FailOnSecondStart:
def __init__(self) -> None:
self._real_tg = real_create_task_group()
self._calls = 0
async def __aenter__(self) -> "_FailOnSecondStart":
await self._real_tg.__aenter__()
return self
async def __aexit__(self, *exc_info: Any) -> bool | None:
return await self._real_tg.__aexit__(*exc_info)
def start_soon(self, func: Any, *args: Any) -> None:
self._calls += 1
if self._calls == 2:
raise RuntimeError("interrupted while scheduling")
self._real_tg.start_soon(func, *args)
monkeypatch.setattr(async_utils.anyio, "create_task_group", _FailOnSecondStart)
created: list[Any] = []
async def value(result: int) -> int:
return result
def awaitables() -> Iterator[Awaitable[int]]:
for i in range(3):
aw = value(i)
created.append(aw)
yield aw
with pytest.raises(BaseExceptionGroup) as exc_info:
await gather(awaitables())
assert len(exc_info.value.exceptions) == 1
assert isinstance(exc_info.value.exceptions[0], RuntimeError)
assert "interrupted while scheduling" in str(exc_info.value.exceptions[0])
# created[1] was being handed to start_soon() when it raised - it
# must have been closed rather than abandoned.
assert inspect.getcoroutinestate(created[1]) == "CORO_CLOSED"
async def test_closes_unscheduled_coroutines_from_an_eager_caller(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Lazy consumption keeps the leak window small, but a caller that
builds its awaitables eagerly (a list or a parenthesized tuple) has
coroutines queued behind the failing one that were never scheduled
either. ``gather`` drains what is left of the iterable and closes
those too, so it cannot leak regardless of how its argument was
constructed."""
real_create_task_group = anyio.create_task_group
class _FailOnSecondStart:
def __init__(self) -> None:
self._real_tg = real_create_task_group()
self._calls = 0
async def __aenter__(self) -> "_FailOnSecondStart":
await self._real_tg.__aenter__()
return self
async def __aexit__(self, *exc_info: Any) -> bool | None:
return await self._real_tg.__aexit__(*exc_info)
def start_soon(self, func: Any, *args: Any) -> None:
self._calls += 1
if self._calls == 2:
raise RuntimeError("interrupted while scheduling")
self._real_tg.start_soon(func, *args)
monkeypatch.setattr(async_utils.anyio, "create_task_group", _FailOnSecondStart)
async def value(result: int) -> int:
return result
# Eagerly built: all four coroutines exist before gather() runs.
eager = [value(0), value(1), value(2), value(3)]
with pytest.raises(BaseExceptionGroup):
await gather(eager)
# The one that failed to schedule *and* the two queued behind it are
# all closed; none is left to surface as a stray warning later.
assert [inspect.getcoroutinestate(aw) for aw in eager[1:]] == [
"CORO_CLOSED"
] * 3
class TestAsyncPartialIntegration:
async def test_async_partial_tool_runs(self) -> None: