mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-20 20:44:17 +02:00
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:
parent
39aebcf578
commit
3321644ad3
28 changed files with 411 additions and 358 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:*"]
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
11
uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue