Replace subprocess tests with in-process async servers (#2006)

* Use anyio as testing backend

* Remove asyncio markers

* Update streamable http tests

* Replace all subprocess tests

* Replace anyio task groups with asyncio context managers in tests

- Convert run_server_async from anyio task group pattern to asyncio.create_task with async context manager
- Remove task_group fixture from conftest
- Update all test fixtures to use async with run_server_async pattern
- Remove TaskGroup imports from all test files
- Tests now work with pytest-asyncio instead of pytest-anyio

* Update test_github_provider_integration.py
This commit is contained in:
Jeremiah Lowin 2025-10-19 10:47:54 -04:00 committed by GitHub
commit 3321644ad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 411 additions and 358 deletions

View file

@ -297,7 +297,53 @@ 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. Use the `run_server_in_process` utility to spawn a server in a separate process for testing:
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 using AnyIO task groups (preferred), and separate subprocess servers (for special cases).
#### In-Process Network Testing (Preferred)
For most network transport tests, use `run_server_async` with AnyIO task groups. This runs the server as a task in the same process, providing fast, deterministic tests with full debugger support:
```python
import pytest
from anyio.abc import TaskGroup
from fastmcp import FastMCP, Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
def create_test_server() -> FastMCP:
"""Create a test server instance."""
server = FastMCP("TestServer")
@server.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
return server
@pytest.fixture
async def http_server(task_group: TaskGroup) -> str:
"""Start server in-process using task group."""
server = create_test_server()
url = await run_server_async(task_group, server, transport="http")
return 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
greeting = await client.call_tool("greet", {"name": "World"})
assert greeting.data == "Hello, World!"
```
The `task_group` fixture is provided globally by `conftest.py` and automatically handles server lifecycle and cleanup. This approach is faster than subprocess-based testing and provides better error messages.
#### Subprocess Testing (Special Cases)
For tests that require complete process isolation (like STDIO transport or testing subprocess behavior), use `run_server_in_process`:
```python
import pytest
@ -328,12 +374,9 @@ async def test_http_transport(http_server: str):
) as client:
result = await client.ping()
assert result is True
greeting = await client.call_tool("greet", {"name": "World"})
assert greeting.data == "Hello, World!"
```
The `run_server_in_process` utility handles server lifecycle, port allocation, and cleanup automatically. This pattern is essential for testing transport-specific behavior like timeouts, headers, and authentication. Note that FastMCP often uses the `client_process` marker to isolate tests that spawn processes, as they can create contention in CI.
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.
### Documentation Testing

View file

@ -17,6 +17,7 @@ dependencies = [
"openapi-core>=0.19.5",
"py-key-value-aio[disk,memory]>=0.2.2,<0.3.0",
"websockets>=15.0.1",
"pytest-asyncio>=1.2.0",
]
requires-python = ">=3.10"
@ -59,7 +60,6 @@ dev = [
"pyinstrument>=5.0.2",
"pyperclip>=1.9.0",
"pytest>=8.3.3",
"pytest-asyncio>=0.23.5",
"pytest-cov>=6.1.1",
"pytest-env>=1.1.5",
"pytest-flakefinder",
@ -99,8 +99,6 @@ fallback-version = "0.0.0"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"
# filterwarnings = ["error::DeprecationWarning"]
timeout = 5
env = [

View file

@ -6,7 +6,6 @@ import inspect
import logging
import warnings
import weakref
from asyncio.locks import Lock
from collections.abc import Generator, Mapping, Sequence
from contextlib import contextmanager
from contextvars import ContextVar, Token
@ -15,6 +14,7 @@ from enum import Enum
from logging import Logger
from typing import Any, Literal, cast, get_origin, overload
import anyio
from mcp import LoggingLevel, ServerSession
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import request_ctx
@ -61,7 +61,7 @@ _clamp_logger(logger=to_client_logger, max_level="DEBUG")
T = TypeVar("T", default=Any)
_current_context: ContextVar[Context | None] = ContextVar("context", default=None) # type: ignore[assignment]
_flush_lock: Lock = asyncio.Lock()
_flush_lock = anyio.Lock()
@dataclass

View file

@ -1,11 +1,11 @@
"""Rate limiting middleware for protecting FastMCP servers from abuse."""
import asyncio
import time
from collections import defaultdict, deque
from collections.abc import Callable
from typing import Any
import anyio
from mcp import McpError
from mcp.types import ErrorData
@ -33,7 +33,7 @@ class TokenBucketRateLimiter:
self.refill_rate = refill_rate
self.tokens = capacity
self.last_refill = time.time()
self._lock = asyncio.Lock()
self._lock = anyio.Lock()
async def consume(self, tokens: int = 1) -> bool:
"""Try to consume tokens from the bucket.
@ -71,7 +71,7 @@ class SlidingWindowRateLimiter:
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests = deque()
self._lock = asyncio.Lock()
self._lock = anyio.Lock()
async def is_allowed(self) -> bool:
"""Check if a request is allowed."""

View file

@ -5,8 +5,8 @@ import logging
import multiprocessing
import socket
import time
from collections.abc import Callable, Generator
from contextlib import contextmanager
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import asynccontextmanager, contextmanager
from typing import TYPE_CHECKING, Any, Literal
from urllib.parse import parse_qs, urlparse
@ -140,6 +140,88 @@ def run_server_in_process(
raise RuntimeError("Server process failed to terminate even after kill")
@asynccontextmanager
async def run_server_async(
server: FastMCP,
port: int | None = None,
transport: Literal["http", "streamable-http", "sse"] = "http",
path: str = "/mcp",
host: str = "127.0.0.1",
) -> AsyncGenerator[str, None]:
"""
Start a FastMCP server as an asyncio task for in-process async testing.
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.
Args:
server: FastMCP server instance
port: Port to bind to (default: find available port)
transport: Transport type ("http", "streamable-http", or "sse")
path: URL path for the server (default: "/mcp")
host: Host to bind to (default: "127.0.0.1")
Yields:
Server URL string
Example:
```python
import pytest
from fastmcp import FastMCP, Client
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.utilities.tests import run_server_async
@pytest.fixture
async def server():
mcp = FastMCP("test")
@mcp.tool()
def greet(name: str) -> str:
return f"Hello, {name}!"
async with run_server_async(mcp) as url:
yield url
async def test_greet(server: str):
async with Client(StreamableHttpTransport(server)) as client:
result = await client.call_tool("greet", {"name": "World"})
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(
host=host,
port=port,
transport=transport,
path=path,
show_banner=False,
)
)
# Give the server a moment to start
await asyncio.sleep(0.1)
try:
yield f"http://{host}:{port}{path}"
finally:
# Cleanup: cancel the task
server_task.cancel()
try:
await server_task
except asyncio.CancelledError:
pass
@contextmanager
def caplog_for_fastmcp(caplog):
"""Context manager to capture logs from FastMCP loggers even when propagation is disabled."""

View file

@ -84,7 +84,6 @@ class TestConfigFileDetection:
class TestConfigWithClient:
"""Test fastmcp.json configuration with client connections."""
@pytest.mark.asyncio
async def test_config_server_with_client(self, server_with_config):
"""Test that a server loaded from config works with a client."""
# Load the config

View file

@ -11,7 +11,6 @@ from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystem
class TestServerArguments:
"""Test passing arguments to servers."""
@pytest.mark.asyncio
async def test_server_with_argparse(self, tmp_path):
"""Test a server that uses argparse with command line arguments."""
server_file = tmp_path / "argparse_server.py"
@ -53,7 +52,6 @@ def get_config() -> dict:
tools = await server.get_tools()
assert "get_config" in tools
@pytest.mark.asyncio
async def test_server_with_no_args(self, tmp_path):
"""Test a server that uses argparse with no arguments (defaults)."""
server_file = tmp_path / "default_server.py"
@ -79,7 +77,6 @@ mcp = FastMCP(args.name)
assert server.name == "DefaultName"
@pytest.mark.asyncio
async def test_server_with_sys_argv_access(self, tmp_path):
"""Test a server that directly accesses sys.argv."""
server_file = tmp_path / "sysargv_server.py"
@ -112,7 +109,6 @@ mcp = FastMCP(name)
assert server.name == "DirectServer"
@pytest.mark.asyncio
async def test_config_server_example(self):
"""Test the actual config_server.py example."""
# Find the examples directory

View file

@ -1,4 +1,3 @@
from collections.abc import Generator
from urllib.parse import urlparse
import httpx
@ -9,7 +8,8 @@ from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.auth import ClientRegistrationOptions
from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
from fastmcp.utilities.http import find_available_port
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
def fastmcp_server(issuer_url: str):
@ -35,31 +35,27 @@ def fastmcp_server(issuer_url: str):
return server
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs)
@pytest.fixture
async def streamable_http_server():
"""Start OAuth-enabled server."""
port = find_available_port()
server = fastmcp_server(f"http://127.0.0.1:{port}")
async with run_server_async(server, port=port, transport="http") as url:
yield url
@pytest.fixture
def streamable_http_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="http") as url:
yield f"{url}/mcp"
@pytest.fixture()
def client_unauthorized(streamable_http_server: str) -> Client:
return Client(transport=StreamableHttpTransport(streamable_http_server))
@pytest.fixture()
def client_with_headless_oauth(
streamable_http_server: str,
) -> Generator[Client, None, None]:
@pytest.fixture
def client_with_headless_oauth(streamable_http_server: str) -> Client:
"""Client with headless OAuth that bypasses browser interaction."""
client = Client(
return Client(
transport=StreamableHttpTransport(streamable_http_server),
auth=HeadlessOAuth(mcp_url=streamable_http_server),
)
yield client
async def test_unauthorized(client_unauthorized: Client):

View file

@ -1,19 +1,16 @@
import json
from collections.abc import Generator
import pytest
from fastapi import FastAPI, Request
import fastmcp
from fastmcp import Client, FastMCP
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.experimental.server.openapi import MCPType, RouteMap
from fastmcp.utilities.tests import run_server_in_process
from fastmcp.utilities.tests import run_server_async, temporary_settings
def fastmcp_server_for_headers() -> FastMCP:
fastmcp.settings.experimental.enable_new_openapi_parser = True
def create_fastmcp_server_for_headers() -> FastMCP:
"""Create a FastMCP server from FastAPI app with experimental parser."""
app = FastAPI()
@app.get("/headers")
@ -46,35 +43,30 @@ def fastmcp_server_for_headers() -> FastMCP:
return mcp
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server_for_headers().run(host=host, port=port, **kwargs)
def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
app = FastMCP.as_proxy(StreamableHttpTransport(shttp_url))
app.run(host=host, port=port, **kwargs)
@pytest.fixture
async def shttp_server():
"""Start a test server with StreamableHttp transport."""
with temporary_settings(experimental__enable_new_openapi_parser=True):
server = create_fastmcp_server_for_headers()
async with run_server_async(server, transport="http") as url:
yield url
@pytest.fixture
def shttp_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="http") as url:
yield f"{url}/mcp"
async def sse_server():
"""Start a test server with SSE transport."""
with temporary_settings(experimental__enable_new_openapi_parser=True):
server = create_fastmcp_server_for_headers()
async with run_server_async(server, transport="sse") as url:
yield url
@pytest.fixture
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="sse") as url:
yield f"{url}/sse"
@pytest.fixture
def proxy_server(shttp_server: str) -> Generator[str, None, None]:
with run_server_in_process(
run_proxy_server,
shttp_url=shttp_server,
transport="http",
) as url:
yield f"{url}/mcp"
async def proxy_server(shttp_server: str):
"""Start a proxy server."""
proxy = FastMCP.as_proxy(StreamableHttpTransport(shttp_server))
async with run_server_async(proxy, transport="http") as url:
yield url
async def test_fastapi_client_headers_streamable_http_resource(shttp_server: str):

View file

@ -1,5 +1,4 @@
import json
from collections.abc import Generator
import pytest
from fastapi import FastAPI, Request
@ -7,7 +6,7 @@ from fastapi import FastAPI, Request
from fastmcp import Client, FastMCP
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.server.openapi import MCPType, RouteMap
from fastmcp.utilities.tests import run_server_in_process
from fastmcp.utilities.tests import run_server_async
def fastmcp_server_for_headers() -> FastMCP:
@ -43,35 +42,28 @@ def fastmcp_server_for_headers() -> FastMCP:
return mcp
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server_for_headers().run(host=host, port=port, **kwargs)
def run_proxy_server(host: str, port: int, shttp_url: str, **kwargs) -> None:
app = FastMCP.as_proxy(StreamableHttpTransport(shttp_url))
app.run(host=host, port=port, **kwargs)
@pytest.fixture
async def shttp_server():
"""Start a test server with StreamableHttp transport."""
server = fastmcp_server_for_headers()
async with run_server_async(server, transport="http") as url:
yield url
@pytest.fixture
def shttp_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="http") as url:
yield f"{url}/mcp"
async def sse_server():
"""Start a test server with SSE transport."""
server = fastmcp_server_for_headers()
async with run_server_async(server, transport="sse") as url:
yield url
@pytest.fixture
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="sse") as url:
yield f"{url}/sse"
@pytest.fixture
def proxy_server(shttp_server: str) -> Generator[str, None, None]:
with run_server_in_process(
run_proxy_server,
shttp_url=shttp_server,
transport="http",
) as url:
yield f"{url}/mcp"
async def proxy_server(shttp_server: str):
"""Start a proxy server."""
proxy = FastMCP.as_proxy(StreamableHttpTransport(shttp_server))
async with run_server_async(proxy, transport="http") as url:
yield url
async def test_fastapi_client_headers_streamable_http_resource(shttp_server: str):

View file

@ -1,32 +1,26 @@
import asyncio
import json
import sys
from collections.abc import Generator
import pytest
import uvicorn
from mcp import McpError
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp.client import Client
from fastmcp.client.transports import SSETransport
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_in_process
from fastmcp.utilities.tests import run_server_async
def fastmcp_server():
"""Fixture that creates a FastMCP server with tools, resources, and prompts."""
def create_test_server() -> FastMCP:
"""Create a FastMCP server with tools, resources, and prompts."""
server = FastMCP("TestServer")
# Add a tool
@server.tool
def greet(name: str) -> str:
"""Greet someone by name."""
return f"Hello, {name}!"
# Add a second tool
@server.tool
def add(a: int, b: int) -> int:
"""Add two numbers together."""
@ -38,12 +32,10 @@ def fastmcp_server():
await asyncio.sleep(seconds)
return f"Slept for {seconds} seconds"
# Add a resource
@server.resource(uri="data://users")
async def get_users():
return ["Alice", "Bob", "Charlie"]
# Add a resource template
@server.resource(uri="data://user/{user_id}")
async def get_user(user_id: str):
return {"id": user_id, "name": f"User {user_id}", "active": True}
@ -51,10 +43,8 @@ def fastmcp_server():
@server.resource(uri="request://headers")
async def get_headers() -> dict[str, str]:
request = get_http_request()
return dict(request.headers)
# Add a prompt
@server.prompt
def welcome(name: str) -> str:
"""Example greeting prompt."""
@ -63,14 +53,12 @@ def fastmcp_server():
return server
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server().run(host=host, port=port, **kwargs)
@pytest.fixture(autouse=True)
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="sse") as url:
yield f"{url}/sse"
@pytest.fixture
async def sse_server():
"""Start a test server with SSE transport and return its URL."""
server = create_test_server()
async with run_server_async(server, transport="sse") as url:
yield url
async def test_ping(sse_server: str):
@ -91,36 +79,66 @@ async def test_http_headers(sse_server: str):
assert json_result["x-demo-header"] == "ABC"
def run_nested_server(host: str, port: int) -> None:
fastmcp = fastmcp_server()
app = fastmcp.sse_app(path="/mcp/sse/", message_path="/mcp/messages")
mount = Starlette(routes=[Mount("/nest-inner", app=app)])
mount2 = Starlette(routes=[Mount("/nest-outer", app=mount)])
server = uvicorn.Server(
config=uvicorn.Config(
app=mount2, host=host, port=port, log_level="error", ws="websockets-sansio"
)
@pytest.fixture
async def sse_server_custom_path():
"""Start a test server with SSE on a custom path."""
server = create_test_server()
async with run_server_async(server, transport="sse", path="/help") as url:
yield url
@pytest.fixture
async def nested_sse_server():
"""Test nested server mounts with SSE."""
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp.utilities.http import find_available_port
server = create_test_server()
sse_app = server.sse_app(path="/mcp/sse/", message_path="/mcp/messages")
# Nest the app under multiple mounts to test URL resolution
inner = Starlette(routes=[Mount("/nest-inner", app=sse_app)])
outer = Starlette(routes=[Mount("/nest-outer", app=inner)])
# Run uvicorn with the nested ASGI app
port = find_available_port()
config = uvicorn.Config(
app=outer,
host="127.0.0.1",
port=port,
log_level="critical",
ws="websockets-sansio",
)
server.run()
server_task = asyncio.create_task(uvicorn.Server(config).serve())
await asyncio.sleep(0.1)
try:
yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/mcp/sse/"
finally:
server_task.cancel()
try:
await server_task
except asyncio.CancelledError:
pass
async def test_run_server_on_path():
with run_server_in_process(run_server, transport="sse", path="/help") as url:
async with Client(transport=SSETransport(f"{url}/help")) as client:
result = await client.ping()
assert result is True
async def test_run_server_on_path(sse_server_custom_path: str):
"""Test running server on a custom path."""
async with Client(transport=SSETransport(sse_server_custom_path)) as client:
result = await client.ping()
assert result is True
async def test_nested_sse_server_resolves_correctly():
# tests patch for
# https://github.com/modelcontextprotocol/python-sdk/pull/659
with run_server_in_process(run_nested_server) as url:
async with Client(
transport=SSETransport(f"{url}/nest-outer/nest-inner/mcp/sse/")
) as client:
result = await client.ping()
assert result is True
async def test_nested_sse_server_resolves_correctly(nested_sse_server: str):
"""Test patch for https://github.com/modelcontextprotocol/python-sdk/pull/659"""
async with Client(transport=SSETransport(nested_sse_server)) as client:
result = await client.ping()
assert result is True
@pytest.mark.skipif(

View file

@ -1,28 +1,23 @@
import asyncio
import json
import sys
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, call
import pytest
import uvicorn
from mcp import McpError
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp import Context
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_in_process
from fastmcp.utilities.tests import run_server_async
def fastmcp_server():
"""Fixture that creates a FastMCP server with tools, resources, and prompts."""
def create_test_server() -> FastMCP:
"""Create a FastMCP server with tools, resources, and prompts."""
server = FastMCP("TestServer")
# Add a tool
@server.tool
def greet(name: str) -> str:
"""Greet someone by name."""
@ -38,7 +33,6 @@ def fastmcp_server():
else:
return "No name provided"
# Add a second tool
@server.tool
def add(a: int, b: int) -> int:
"""Add two numbers together."""
@ -57,12 +51,10 @@ def fastmcp_server():
await ctx.report_progress(0.75, 1.0, "Almost there!")
return f"Hello, {name}!"
# Add a resource
@server.resource(uri="data://users")
async def get_users():
return ["Alice", "Bob", "Charlie"]
# Add a resource template
@server.resource(uri="data://user/{user_id}")
async def get_user(user_id: str):
return {"id": user_id, "name": f"User {user_id}", "active": True}
@ -70,10 +62,8 @@ def fastmcp_server():
@server.resource(uri="request://headers")
async def get_headers() -> dict[str, str]:
request = get_http_request()
return dict(request.headers)
# Add a prompt
@server.prompt
def welcome(name: str) -> str:
"""Example greeting prompt."""
@ -82,51 +72,72 @@ def fastmcp_server():
return server
def run_server(host: str, port: int, stateless_http: bool = False, **kwargs) -> None:
server = fastmcp_server()
server.settings.stateless_http = stateless_http
server.run(host=host, port=port, **kwargs)
@pytest.fixture
async def streamable_http_server(request):
"""Start a test server and return its URL."""
import fastmcp
def run_nested_server(host: str, port: int) -> None:
mcp_app = fastmcp_server().http_app(path="/final/mcp")
mount = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
mount2 = Starlette(
routes=[Mount("/nest-outer", app=mount)],
lifespan=mcp_app.lifespan,
)
server = uvicorn.Server(
config=uvicorn.Config(
app=mount2,
host=host,
port=port,
log_level="error",
lifespan="on",
ws="websockets-sansio",
)
)
server.run()
@pytest.fixture()
async def streamable_http_server(
request,
) -> AsyncGenerator[str, None]:
stateless_http = getattr(request, "param", False)
with run_server_in_process(
run_server, stateless_http=stateless_http, transport="http"
) as url:
yield f"{url}/mcp"
if stateless_http:
fastmcp.settings.stateless_http = True
server = create_test_server()
async with run_server_async(server) as url:
yield url
if stateless_http:
fastmcp.settings.stateless_http = False
@pytest.fixture()
async def streamable_http_server_with_streamable_http_alias() -> AsyncGenerator[
str, None
]:
@pytest.fixture
async def streamable_http_server_with_streamable_http_alias():
"""Test that the "streamable-http" transport alias works."""
with run_server_in_process(run_server, transport="streamable-http") as url:
yield f"{url}/mcp"
server = create_test_server()
async with run_server_async(server, transport="streamable-http") as url:
yield url
@pytest.fixture
async def nested_server():
"""Test nested server mounts with Starlette."""
import uvicorn
from starlette.applications import Starlette
from starlette.routing import Mount
from fastmcp.utilities.http import find_available_port
server = create_test_server()
mcp_app = server.http_app(path="/final/mcp")
# Nest the app under multiple mounts to test URL resolution
inner = Starlette(routes=[Mount("/nest-inner", app=mcp_app)])
outer = Starlette(
routes=[Mount("/nest-outer", app=inner)], lifespan=mcp_app.lifespan
)
# Run uvicorn with the nested ASGI app
port = find_available_port()
config = uvicorn.Config(
app=outer,
host="127.0.0.1",
port=port,
log_level="critical",
ws="websockets-sansio",
)
# Use the simple asyncio pattern
server_task = asyncio.create_task(uvicorn.Server(config).serve())
await asyncio.sleep(0.1)
yield f"http://127.0.0.1:{port}/nest-outer/nest-inner/final/mcp"
# Cleanup
server_task.cancel()
try:
await server_task
except asyncio.CancelledError:
pass
async def test_ping(streamable_http_server: str):
@ -203,16 +214,11 @@ async def test_elicitation_tool(streamable_http_server: str, request):
assert result.data == "You said your name was: Alice!"
async def test_nested_streamable_http_server_resolves_correctly():
# tests patch for
# https://github.com/modelcontextprotocol/python-sdk/pull/659
with run_server_in_process(run_nested_server) as url:
async with Client(
transport=StreamableHttpTransport(f"{url}/nest-outer/nest-inner/final/mcp")
) as client:
result = await client.ping()
assert result is True
async def test_nested_streamable_http_server_resolves_correctly(nested_server: str):
"""Test patch for https://github.com/modelcontextprotocol/python-sdk/pull/659"""
async with Client(transport=StreamableHttpTransport(nested_server)) as client:
result = await client.ping()
assert result is True
@pytest.mark.skipif(

View file

@ -352,7 +352,6 @@ class TestOpenAPIComprehensive:
},
}
@pytest.mark.asyncio
async def test_comprehensive_server_initialization(
self, comprehensive_openapi_spec
):
@ -387,7 +386,6 @@ class TestOpenAPIComprehensive:
assert tool_names == expected_operations
@pytest.mark.asyncio
async def test_openapi_31_compatibility(self, openapi_31_spec):
"""Test that OpenAPI 3.1 specs work correctly."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -404,7 +402,6 @@ class TestOpenAPIComprehensive:
tool = tools[0]
assert tool.name == "get_item_31"
@pytest.mark.asyncio
async def test_parameter_collision_handling(self, comprehensive_openapi_spec):
"""Test that parameter collisions are handled correctly."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -436,7 +433,6 @@ class TestOpenAPIComprehensive:
# Should have other parameters
assert "data" in param_names
@pytest.mark.asyncio
async def test_deep_object_parameters(self, comprehensive_openapi_spec):
"""Test deepObject parameter handling."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -462,7 +458,6 @@ class TestOpenAPIComprehensive:
filter_params = [name for name in properties.keys() if "filter" in name]
assert len(filter_params) > 0
@pytest.mark.asyncio
async def test_request_building_and_execution(self, comprehensive_openapi_spec):
"""Test that requests are built and executed correctly."""
# Create a mock client that tracks requests
@ -503,7 +498,6 @@ class TestOpenAPIComprehensive:
assert "123" in str(request.url)
assert "users/123" in str(request.url)
@pytest.mark.asyncio
async def test_complex_request_with_body_and_parameters(
self, comprehensive_openapi_spec
):
@ -554,7 +548,6 @@ class TestOpenAPIComprehensive:
assert body_data["email"] == "new@example.com"
assert body_data["age"] == 25
@pytest.mark.asyncio
async def test_query_parameters(self, comprehensive_openapi_spec):
"""Test query parameter handling."""
mock_client = Mock(spec=httpx.AsyncClient)
@ -593,7 +586,6 @@ class TestOpenAPIComprehensive:
assert "offset=10" in url_str
assert "sort=name" in url_str
@pytest.mark.asyncio
async def test_error_handling(self, comprehensive_openapi_spec):
"""Test error handling for HTTP errors."""
mock_client = Mock(spec=httpx.AsyncClient)
@ -630,7 +622,6 @@ class TestOpenAPIComprehensive:
error_message = str(exc_info.value)
assert "404" in error_message
@pytest.mark.asyncio
async def test_schema_refs_resolution(self, comprehensive_openapi_spec):
"""Test that schema references are resolved correctly."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -652,7 +643,6 @@ class TestOpenAPIComprehensive:
assert "email" in properties
# May also have id and age depending on implementation
@pytest.mark.asyncio
async def test_optional_vs_required_parameters(self, comprehensive_openapi_spec):
"""Test handling of optional vs required parameters."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -680,7 +670,6 @@ class TestOpenAPIComprehensive:
# Should have some required parameters
assert len(search_schema["properties"]) > 0
@pytest.mark.asyncio
async def test_server_performance_no_latency(self, comprehensive_openapi_spec):
"""Test that server initialization is fast (no code generation latency)."""
import time

View file

@ -174,7 +174,6 @@ class TestDeepObjectStyle:
},
}
@pytest.mark.asyncio
async def test_deepobject_style_parsing_from_spec(self, deepobject_spec):
"""Test that deepObject style parameters are correctly parsed from OpenAPI spec."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -208,7 +207,6 @@ class TestDeepObjectStyle:
# Should have some structure, exact format may vary
assert target_param is not None
@pytest.mark.asyncio
async def test_deepobject_explode_true_handling(self, deepobject_spec):
"""Test deepObject with explode=true parameter handling."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -234,7 +232,6 @@ class TestDeepObjectStyle:
assert "type" in target_properties
assert target_properties["type"]["enum"] == ["location", "organisation"]
@pytest.mark.asyncio
async def test_deepobject_explode_false_handling(self, deepobject_spec):
"""Test deepObject with explode=false parameter handling."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -263,7 +260,6 @@ class TestDeepObjectStyle:
if "type" in compact_param:
assert compact_param["type"] == "object"
@pytest.mark.asyncio
async def test_nested_object_structure_in_request_body(self, deepobject_spec):
"""Test nested object structures in request body are preserved."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -308,7 +304,6 @@ class TestDeepObjectStyle:
assert "push" in notif_props
assert "frequency" in notif_props
@pytest.mark.asyncio
async def test_deepobject_tool_functionality(self, deepobject_spec):
"""Test that tools with deepObject parameters maintain basic functionality."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:

View file

@ -124,7 +124,6 @@ class TestParameterHandling:
},
}
@pytest.mark.asyncio
async def test_query_parameters_in_tools(self, parameter_spec):
"""Test that query parameters are properly included in tool parameters."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -173,7 +172,6 @@ class TestParameterHandling:
assert "query" in required
assert "X-API-Key" in required
@pytest.mark.asyncio
async def test_path_parameters_in_tools(self, parameter_spec):
"""Test that path parameters are properly included in tool parameters."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -279,7 +277,6 @@ class TestRequestBodyHandling:
},
}
@pytest.mark.asyncio
async def test_request_body_properties_in_tool(self, request_body_spec):
"""Test that request body properties are included in tool parameters."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -381,7 +378,6 @@ class TestResponseSchemas:
},
}
@pytest.mark.asyncio
async def test_tool_has_output_schema(self, response_schema_spec):
"""Test that tools have output schemas from response definitions."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:

View file

@ -118,7 +118,6 @@ class TestParameterCollisions:
},
}
@pytest.mark.asyncio
async def test_path_body_collision_handling(self, collision_spec):
"""Test that path and body parameters with same name are handled correctly."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -159,7 +158,6 @@ class TestParameterCollisions:
id_required = any("id" in req for req in required)
assert id_required
@pytest.mark.asyncio
async def test_query_header_collision_handling(self, collision_spec):
"""Test that query and header parameters with same name are handled correctly."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -190,7 +188,6 @@ class TestParameterCollisions:
query_required = any("query" in req for req in required)
assert query_required
@pytest.mark.asyncio
async def test_collision_resolution_maintains_functionality(self, collision_spec):
"""Test that collision resolution doesn't break basic tool functionality."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:

View file

@ -112,7 +112,6 @@ class TestFastMCPOpenAPIBasicFunctionality:
# Should use default name
assert server.name == "OpenAPI FastMCP"
@pytest.mark.asyncio
async def test_server_creates_tools_from_spec(self, simple_openapi_spec):
"""Test that server creates tools from OpenAPI spec."""
async with httpx.AsyncClient(base_url="https://api.example.com") as client:
@ -131,7 +130,6 @@ class TestFastMCPOpenAPIBasicFunctionality:
assert "get_user" in tool_names
assert "create_user" in tool_names
@pytest.mark.asyncio
async def test_server_tool_execution_fallback_to_http(self, simple_openapi_spec):
"""Test tool execution falls back to HTTP when callables aren't available."""
# Use a mock client that will be used for HTTP fallback
@ -203,7 +201,6 @@ class TestFastMCPOpenAPIBasicFunctionality:
assert hasattr(server, "_director")
assert hasattr(server, "_spec")
@pytest.mark.asyncio
async def test_clean_schema_output_no_unused_defs(self):
"""Test that unused schema definitions are removed from tool schemas."""
# Create a spec with unused HTTPValidationError-like definitions

View file

@ -12,7 +12,7 @@ with the following configuration:
import os
import re
from collections.abc import Generator
from collections.abc import AsyncGenerator
from urllib.parse import parse_qs, urlparse
import httpx
@ -21,7 +21,7 @@ import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
from fastmcp.server.auth.providers.github import GitHubProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID = os.getenv("FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID")
FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET = os.getenv(
@ -36,7 +36,7 @@ pytestmark = pytest.mark.xfail(
)
def create_github_server(host: str = "127.0.0.1", port: int = 9100, **kwargs) -> None:
def create_github_server(base_url: str) -> FastMCP:
"""Create FastMCP server with GitHub OAuth protection."""
assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID is not None
assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET is not None
@ -45,7 +45,7 @@ def create_github_server(host: str = "127.0.0.1", port: int = 9100, **kwargs) ->
auth = GitHubProvider(
client_id=FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID,
client_secret=FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET,
base_url=f"http://{host}:{port}",
base_url=base_url,
)
# Create FastMCP server with GitHub authentication
@ -61,13 +61,10 @@ def create_github_server(host: str = "127.0.0.1", port: int = 9100, **kwargs) ->
"""Returns user info from OAuth context."""
return "📝 GitHub OAuth user authenticated successfully"
# Run the server
server.run(host=host, port=port, **kwargs)
return server
def create_github_server_with_mock_callback(
host: str = "127.0.0.1", port: int = 9100, **kwargs
) -> None:
def create_github_server_with_mock_callback(base_url: str) -> FastMCP:
"""Create FastMCP server with GitHub OAuth that mocks the callback for testing."""
assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID is not None
assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET is not None
@ -76,7 +73,7 @@ def create_github_server_with_mock_callback(
auth = GitHubProvider(
client_id=FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID,
client_secret=FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET,
base_url=f"http://{host}:{port}",
base_url=base_url,
)
# Mock the authorize method to return a fake code instead of redirecting to GitHub
@ -159,29 +156,31 @@ def create_github_server_with_mock_callback(
"""Returns user info from OAuth context."""
return "📝 GitHub OAuth user authenticated successfully"
# Run the server
server.run(host=host, port=port, **kwargs)
return server
@pytest.fixture(scope="module")
def github_server() -> Generator[str, None, None]:
"""Start GitHub OAuth server in background process on fixed port 9100."""
with run_server_in_process(
create_github_server, transport="http", host="127.0.0.1", port=9100
) as url:
yield f"{url}/mcp"
@pytest.fixture
async def github_server() -> AsyncGenerator[str, None]:
"""Start GitHub OAuth server on a random available port."""
from fastmcp.utilities.http import find_available_port
port = find_available_port()
base_url = f"http://127.0.0.1:{port}"
server = create_github_server(base_url)
async with run_server_async(server, port=port, transport="http") as url:
yield url
@pytest.fixture(scope="module")
def github_server_with_mock() -> Generator[str, None, None]:
"""Start GitHub OAuth server with mocked callback in background process on port 9101."""
with run_server_in_process(
create_github_server_with_mock_callback,
transport="http",
host="127.0.0.1",
port=9101,
) as url:
yield f"{url}/mcp"
@pytest.fixture
async def github_server_with_mock() -> AsyncGenerator[str, None]:
"""Start GitHub OAuth server with mocked callback on a random available port."""
from fastmcp.utilities.http import find_available_port
port = find_available_port()
base_url = f"http://127.0.0.1:{port}"
server = create_github_server_with_mock_callback(base_url)
async with run_server_async(server, port=port, transport="http") as url:
yield url
@pytest.fixture

View file

@ -1,7 +1,6 @@
"""Tests for Descope OAuth provider."""
import os
from collections.abc import Generator
from unittest.mock import patch
import httpx
@ -10,7 +9,7 @@ import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.providers.descope import DescopeProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
class TestDescopeProvider:
@ -118,7 +117,9 @@ class TestDescopeProvider:
assert provider.token_verifier.audience == "P2abc123" # type: ignore[attr-defined]
def run_mcp_server(host: str, port: int) -> None:
@pytest.fixture
async def mcp_server_url():
"""Start Descope server."""
mcp = FastMCP(
auth=DescopeProvider(
project_id="P2test123",
@ -131,25 +132,17 @@ def run_mcp_server(host: str, port: int) -> None:
def add(a: int, b: int) -> int:
return a + b
mcp.run(host=host, port=port, transport="http")
async with run_server_async(mcp, transport="http") as url:
yield url
@pytest.fixture
def mcp_server_url() -> Generator[str]:
with run_server_in_process(run_mcp_server) as url:
yield f"{url}/mcp"
@pytest.fixture()
def client_with_headless_oauth(
mcp_server_url: str,
) -> Generator[Client, None, None]:
def client_with_headless_oauth(mcp_server_url: str) -> Client:
"""Client with headless OAuth that bypasses browser interaction."""
client = Client(
return Client(
transport=StreamableHttpTransport(mcp_server_url),
auth=HeadlessOAuth(mcp_url=mcp_server_url),
)
yield client
class TestDescopeProviderIntegration:

View file

@ -177,7 +177,6 @@ class TestGitHubTokenVerifier:
) # Parent TokenVerifier sets empty list as default
assert verifier.timeout_seconds == 10
@pytest.mark.asyncio
async def test_verify_token_github_api_failure(self):
"""Test token verification when GitHub API returns error."""
verifier = GitHubTokenVerifier()
@ -196,7 +195,6 @@ class TestGitHubTokenVerifier:
result = await verifier.verify_token("invalid_token")
assert result is None
@pytest.mark.asyncio
async def test_verify_token_success(self):
"""Test successful token verification."""
from unittest.mock import AsyncMock

View file

@ -1,7 +1,6 @@
"""Tests for Scalekit OAuth provider."""
import os
from collections.abc import Generator
from unittest.mock import patch
import httpx
@ -10,7 +9,7 @@ import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.providers.scalekit import ScalekitProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
class TestScalekitProvider:
@ -109,7 +108,9 @@ class TestScalekitProvider:
)
def run_mcp_server(host: str, port: int) -> None:
@pytest.fixture
async def mcp_server_url():
"""Start Scalekit server."""
mcp = FastMCP(
auth=ScalekitProvider(
environment_url="https://test-env.scalekit.com",
@ -123,25 +124,17 @@ def run_mcp_server(host: str, port: int) -> None:
def add(a: int, b: int) -> int:
return a + b
mcp.run(host=host, port=port, transport="http")
async with run_server_async(mcp, transport="http") as url:
yield url
@pytest.fixture
def mcp_server_url() -> Generator[str]:
with run_server_in_process(run_mcp_server) as url:
yield f"{url}/mcp"
@pytest.fixture()
def client_with_headless_oauth(
mcp_server_url: str,
) -> Generator[Client, None, None]:
def client_with_headless_oauth(mcp_server_url: str) -> Client:
"""Client with headless OAuth that bypasses browser interaction."""
client = Client(
return Client(
transport=StreamableHttpTransport(mcp_server_url),
auth=HeadlessOAuth(mcp_url=mcp_server_url),
)
yield client
class TestScalekitProviderIntegration:

View file

@ -1,7 +1,6 @@
"""Tests for WorkOS OAuth provider."""
import os
from collections.abc import Generator
from unittest.mock import patch
from urllib.parse import urlparse
@ -11,7 +10,7 @@ import pytest
from fastmcp import Client, FastMCP
from fastmcp.client.transports import StreamableHttpTransport
from fastmcp.server.auth.providers.workos import AuthKitProvider, WorkOSProvider
from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
from fastmcp.utilities.tests import HeadlessOAuth, run_server_async
class TestWorkOSProvider:
@ -157,7 +156,9 @@ class TestWorkOSProvider:
) # WorkOS doesn't support revocation
def run_mcp_server(host: str, port: int) -> None:
@pytest.fixture
async def mcp_server_url():
"""Start AuthKit server."""
mcp = FastMCP(
auth=AuthKitProvider(
authkit_domain="https://respectful-lullaby-34-staging.authkit.app",
@ -169,25 +170,17 @@ def run_mcp_server(host: str, port: int) -> None:
def add(a: int, b: int) -> int:
return a + b
mcp.run(host=host, port=port, transport="http")
async with run_server_async(mcp, transport="http") as url:
yield url
@pytest.fixture
def mcp_server_url() -> Generator[str]:
with run_server_in_process(run_mcp_server) as url:
yield f"{url}/mcp"
@pytest.fixture()
def client_with_headless_oauth(
mcp_server_url: str,
) -> Generator[Client, None, None]:
def client_with_headless_oauth(mcp_server_url: str) -> Client:
"""Client with headless OAuth that bypasses browser interaction."""
client = Client(
return Client(
transport=StreamableHttpTransport(mcp_server_url),
auth=HeadlessOAuth(mcp_url=mcp_server_url),
)
yield client
class TestAuthKitProvider:

View file

@ -1,4 +1,4 @@
from collections.abc import Generator
from collections.abc import AsyncGenerator
from typing import Any
import httpx
@ -8,7 +8,7 @@ from pytest_httpx import HTTPXMock
from fastmcp import Client, FastMCP
from fastmcp.client.auth.bearer import BearerAuth
from fastmcp.server.auth.providers.jwt import JWKData, JWKSData, JWTVerifier, RSAKeyPair
from fastmcp.utilities.tests import run_server_in_process
from fastmcp.utilities.tests import run_server_async
class SymmetricKeyHelper:
@ -111,13 +111,10 @@ def symmetric_provider(symmetric_key_helper: SymmetricKeyHelper) -> JWTVerifier:
)
def run_mcp_server(
def create_mcp_server(
public_key: str,
host: str,
port: int,
auth_kwargs: dict[str, Any] | None = None,
run_kwargs: dict[str, Any] | None = None,
) -> None:
) -> FastMCP:
mcp = FastMCP(
auth=JWTVerifier(
public_key=public_key,
@ -129,21 +126,20 @@ def run_mcp_server(
def add(a: int, b: int) -> int:
return a + b
mcp.run(host=host, port=port, **run_kwargs or {})
return mcp
@pytest.fixture
def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]:
with run_server_in_process(
run_mcp_server,
async def mcp_server_url(rsa_key_pair: RSAKeyPair) -> AsyncGenerator[str, None]:
server = create_mcp_server(
public_key=rsa_key_pair.public_key,
auth_kwargs=dict(
issuer="https://test.example.com",
audience="https://api.example.com",
),
run_kwargs=dict(transport="http"),
) as url:
yield f"{url}/mcp"
)
async with run_server_async(server, transport="http") as url:
yield url
class TestRSAKeyPair:
@ -1021,9 +1017,7 @@ class TestFastMCPBearerAuth:
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
async def test_token_with_insufficient_scopes(
self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
):
async def test_token_with_insufficient_scopes(self, rsa_key_pair: RSAKeyPair):
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
@ -1031,13 +1025,12 @@ class TestFastMCPBearerAuth:
scopes=["read"],
)
with run_server_in_process(
run_mcp_server,
server = create_mcp_server(
public_key=rsa_key_pair.public_key,
auth_kwargs=dict(required_scopes=["read", "write"]),
run_kwargs=dict(transport="http"),
) as url:
mcp_server_url = f"{url}/mcp/"
)
async with run_server_async(server, transport="http") as mcp_server_url:
with pytest.raises(httpx.HTTPStatusError) as exc_info:
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools() # noqa: F841
@ -1048,9 +1041,7 @@ class TestFastMCPBearerAuth:
assert exc_info.value.response.status_code == 401
assert "tools" not in locals()
async def test_token_with_sufficient_scopes(
self, mcp_server_url: str, rsa_key_pair: RSAKeyPair
):
async def test_token_with_sufficient_scopes(self, rsa_key_pair: RSAKeyPair):
token = rsa_key_pair.create_token(
subject="test-user",
issuer="https://test.example.com",
@ -1058,16 +1049,15 @@ class TestFastMCPBearerAuth:
scopes=["read", "write"],
)
with run_server_in_process(
run_mcp_server,
server = create_mcp_server(
public_key=rsa_key_pair.public_key,
auth_kwargs=dict(required_scopes=["read", "write"]),
run_kwargs=dict(transport="http"),
) as url:
mcp_server_url = f"{url}/mcp/"
)
async with run_server_async(server, transport="http") as mcp_server_url:
async with Client(mcp_server_url, auth=BearerAuth(token)) as client:
tools = await client.list_tools()
assert tools
assert tools
class TestJWTVerifierImport:

View file

@ -617,7 +617,6 @@ class TestOAuthProxyTokenEndpointAuth:
)
assert proxy_default._token_endpoint_auth_method is None
@pytest.mark.asyncio
async def test_token_auth_method_passed_to_client(self, jwt_verifier):
"""Test that auth method is passed to AsyncOAuth2Client."""
proxy = OAuthProxy(
@ -738,7 +737,6 @@ class TestOAuthProxyTokenEndpointAuth:
class TestOAuthProxyE2E:
"""End-to-end tests using mock OAuth provider."""
@pytest.mark.asyncio
async def test_full_oauth_flow_with_mock_provider(self, mock_oauth_provider):
"""Test complete OAuth flow with mock provider."""
# Create proxy pointing to mock provider
@ -793,7 +791,6 @@ class TestOAuthProxyE2E:
# Transaction ID itself is used as upstream state parameter
assert transaction.txn_id == txn_id
@pytest.mark.asyncio
async def test_token_refresh_with_mock_provider(self, mock_oauth_provider):
"""Test token refresh flow with mock provider."""
proxy = OAuthProxy(
@ -906,7 +903,6 @@ class TestOAuthProxyE2E:
assert len(result.access_token.split(".")) == 3
assert mock_oauth_provider.refresh_called
@pytest.mark.asyncio
async def test_pkce_validation_with_mock_provider(self, mock_oauth_provider):
"""Test PKCE validation with mock provider."""
mock_oauth_provider.require_pkce = True
@ -1174,7 +1170,6 @@ class TestParameterForwarding:
assert proxy._extra_authorize_params.get("prompt") == "consent"
assert proxy._extra_authorize_params.get("max_age") == "3600"
@pytest.mark.asyncio
async def test_token_endpoint_invalid_client_error(self, jwt_verifier):
"""Test that invalid client_id returns OAuth 2.1 compliant error response.

View file

@ -145,7 +145,6 @@ class TestOAuthProxyRedirectValidation:
assert proxy._allowed_client_redirect_uris == []
@pytest.mark.asyncio
async def test_proxy_register_client_uses_patterns(self):
"""Test that registered clients use the configured patterns."""
custom_patterns = ["https://app.example.com/*"]
@ -178,7 +177,6 @@ class TestOAuthProxyRedirectValidation:
assert isinstance(registered, ProxyDCRClient)
assert registered.allowed_redirect_uri_patterns == custom_patterns
@pytest.mark.asyncio
async def test_proxy_unregistered_client_returns_none(self):
"""Test that unregistered clients return None."""
custom_patterns = ["http://localhost:*", "http://127.0.0.1:*"]

View file

@ -1,5 +1,4 @@
import json
from collections.abc import Generator
import pytest
@ -7,7 +6,7 @@ from fastmcp.client import Client
from fastmcp.client.transports import SSETransport, StreamableHttpTransport
from fastmcp.server.dependencies import get_http_request
from fastmcp.server.server import FastMCP
from fastmcp.utilities.tests import run_server_in_process
from fastmcp.utilities.tests import run_server_async
def fastmcp_server():
@ -38,20 +37,20 @@ def fastmcp_server():
return server
def run_server(host: str, port: int, **kwargs) -> None:
fastmcp_server().run(host=host, port=port, **kwargs)
@pytest.fixture
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
@pytest.fixture(autouse=True)
def shttp_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="http") as url:
yield f"{url}/mcp"
@pytest.fixture(autouse=True)
def sse_server() -> Generator[str, None, None]:
with run_server_in_process(run_server, transport="sse") as url:
yield f"{url}/sse"
@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 def test_http_headers_resource_shttp(shttp_server: str):

View file

@ -137,7 +137,6 @@ class TestSessionId:
class TestContextState:
"""Test suite for Context state functionality."""
@pytest.mark.asyncio
async def test_context_state(self):
"""Test that state modifications in child contexts don't affect parent."""
mock_fastmcp = MagicMock()
@ -152,7 +151,6 @@ class TestContextState:
context.set_state("test1", "new_value")
assert context.get_state("test1") == "new_value"
@pytest.mark.asyncio
async def test_context_state_inheritance(self):
"""Test that child contexts inherit parent state."""
mock_fastmcp = MagicMock()

11
uv.lock generated
View file

@ -556,6 +556,7 @@ dependencies = [
{ name = "py-key-value-aio", extra = ["disk", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" },
{ name = "pytest-asyncio" },
{ name = "python-dotenv" },
{ name = "rich" },
{ name = "websockets" },
@ -580,7 +581,6 @@ dev = [
{ name = "pyinstrument" },
{ name = "pyperclip" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-env" },
{ name = "pytest-flakefinder" },
@ -606,6 +606,7 @@ requires-dist = [
{ name = "py-key-value-aio", extras = ["disk", "memory"], specifier = ">=0.2.2,<0.3.0" },
{ name = "pydantic", extras = ["email"], specifier = ">=2.11.7" },
{ name = "pyperclip", specifier = ">=1.9.0" },
{ name = "pytest-asyncio", specifier = ">=1.2.0" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "rich", specifier = ">=13.9.4" },
{ name = "websockets", specifier = ">=15.0.1" },
@ -625,7 +626,6 @@ dev = [
{ name = "pyinstrument", specifier = ">=5.0.2" },
{ name = "pyperclip", specifier = ">=1.9.0" },
{ name = "pytest", specifier = ">=8.3.3" },
{ name = "pytest-asyncio", specifier = ">=0.23.5" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-env", specifier = ">=1.1.5" },
{ name = "pytest-flakefinder" },
@ -1591,15 +1591,16 @@ wheels = [
[[package]]
name = "pytest-asyncio"
version = "1.1.0"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
]
[[package]]