From 138316715ee54543b0ac0d7384a8cbbc0b373f31 Mon Sep 17 00:00:00 2001 From: deepak-stratforge Date: Thu, 29 May 2025 15:32:46 +0530 Subject: [PATCH 01/34] feat(tool): add support for excluding arguments from tool definition This introduces an `exclude_args` parameter to omit specified arguments (such as `state`, `memory`, etc.). It will exclude the mentioned args from the schema sent to the LLM and still allow them to be passed when the tool is called. This doesn't come with the MCP protocol but can be useful in many use cases. --- src/fastmcp/server/openapi.py | 2 + src/fastmcp/server/server.py | 4 ++ src/fastmcp/tools/tool.py | 16 +++++- src/fastmcp/tools/tool_manager.py | 2 + tests/server/test_tool_exclude_args.py | 76 ++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 tests/server/test_tool_exclude_args.py diff --git a/src/fastmcp/server/openapi.py b/src/fastmcp/server/openapi.py index 70226278e..167e412f6 100644 --- a/src/fastmcp/server/openapi.py +++ b/src/fastmcp/server/openapi.py @@ -226,6 +226,7 @@ class OpenAPITool(Tool): tags: set[str] = set(), timeout: float | None = None, annotations: ToolAnnotations | None = None, + exclude_args: list[str] | None = None, serializer: Callable[[Any], str] | None = None, ): super().__init__( @@ -235,6 +236,7 @@ class OpenAPITool(Tool): fn=self._execute_request, # We'll use an instance method instead of a global function tags=tags, annotations=annotations, + exclude_args=exclude_args, serializer=serializer, ) self._client = client diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 1d0b59ab8..e088703f2 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -497,6 +497,7 @@ class FastMCP(Generic[LifespanResultT]): description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, + exclude_args: list[str] | None = None, ) -> None: """Add a tool to the server. @@ -519,6 +520,7 @@ class FastMCP(Generic[LifespanResultT]): description=description, tags=tags, annotations=annotations, + exclude_args=exclude_args, ) self._cache.clear() @@ -540,6 +542,7 @@ class FastMCP(Generic[LifespanResultT]): description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | dict[str, Any] | None = None, + exclude_args: list[str] | None = None, ) -> Callable[[AnyFunction], AnyFunction]: """Decorator to register a tool. @@ -583,6 +586,7 @@ class FastMCP(Generic[LifespanResultT]): description=description, tags=tags, annotations=annotations, + exclude_args=exclude_args, ) return fn diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index f556bafa7..ded10ff0c 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -46,6 +46,10 @@ class Tool(BaseModel): annotations: ToolAnnotations | None = Field( None, description="Additional annotations about the tool" ) + exclude_args: list[str] | None = Field( + None, + description="Arguments to exclude from the tool schema, such as State, Memory, or Credential", + ) serializer: Callable[[Any], str] | None = Field( None, description="Optional custom serializer for tool results" ) @@ -58,6 +62,7 @@ class Tool(BaseModel): description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, + exclude_args: list[str] | None = None, serializer: Callable[[Any], str] | None = None, ) -> Tool: """Create a Tool from a function.""" @@ -86,10 +91,14 @@ class Tool(BaseModel): schema = type_adapter.json_schema() context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) + temp_prune_params: list[str] = [] if context_kwarg: - prune_params = [context_kwarg] - else: - prune_params = None + temp_prune_params.append(context_kwarg) + if exclude_args: + temp_prune_params.extend(exclude_args) + prune_params: list[str] | None = ( + None if not temp_prune_params else temp_prune_params + ) schema = compress_schema(schema, prune_params=prune_params) @@ -100,6 +109,7 @@ class Tool(BaseModel): parameters=schema, tags=tags or set(), annotations=annotations, + exclude_args=exclude_args, serializer=serializer, ) diff --git a/src/fastmcp/tools/tool_manager.py b/src/fastmcp/tools/tool_manager.py index c511b18fa..e89ba0ef9 100644 --- a/src/fastmcp/tools/tool_manager.py +++ b/src/fastmcp/tools/tool_manager.py @@ -66,6 +66,7 @@ class ToolManager: description: str | None = None, tags: set[str] | None = None, annotations: ToolAnnotations | None = None, + exclude_args: list[str] | None = None, ) -> Tool: """Add a tool to the server.""" tool = Tool.from_function( @@ -75,6 +76,7 @@ class ToolManager: tags=tags, annotations=annotations, serializer=self._serializer, + exclude_args=exclude_args, ) return self.add_tool(tool) diff --git a/tests/server/test_tool_exclude_args.py b/tests/server/test_tool_exclude_args.py new file mode 100644 index 000000000..99e4ef571 --- /dev/null +++ b/tests/server/test_tool_exclude_args.py @@ -0,0 +1,76 @@ +from typing import Any + +from mcp.types import TextContent + +from fastmcp import Client, FastMCP + + +async def test_tool_exclude_args_in_tool_manager(): + """Test that tool args are excluded in the tool manager.""" + mcp = FastMCP("Test Server") + + @mcp.tool(exclude_args=["state"]) + def echo(message: str, state: dict[str, Any] | None = None) -> str: + """Echo back the message provided.""" + if state: + # State was read + pass + return message + + tools = mcp._tool_manager.list_tools() + assert len(tools) == 1 + assert tools[0].exclude_args is not None + for args in tools[0].exclude_args: + assert args not in tools[0].parameters + + +async def test_add_tool_method_exclude_args(): + """Test that tool exclude_args work with the add_tool method.""" + mcp = FastMCP("Test Server") + + def create_item( + name: str, value: int, state: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Create a new item.""" + if state: + # State was read + pass + return {"name": name, "value": value} + + mcp.add_tool(create_item, name="create_item", exclude_args=["state"]) + + # Check internal tool objects directly + tools = mcp._tool_manager.list_tools() + assert len(tools) == 1 + assert tools[0].exclude_args is not None + assert tools[0].exclude_args == ["state"] + for args in tools[0].exclude_args: + assert args not in tools[0].parameters + + +async def test_tool_functionality_with_exclude_args(): + """Test that tool functionality is preserved when using exclude_args.""" + mcp = FastMCP("Test Server") + + def create_item( + name: str, value: int, state: dict[str, Any] | None = None + ) -> dict[str, Any]: + """Create a new item.""" + if state: + # state was read + pass + return {"name": name, "value": value} + + mcp.add_tool(create_item, name="create_item", exclude_args=["state"]) + + # Use the tool to verify functionality is preserved + async with Client(mcp) as client: + result = await client.call_tool( + "create_item", {"name": "test_item", "value": 42} + ) + assert len(result) == 1 + assert isinstance(result[0], TextContent) + + # The result should contain the expected JSON + assert '"name": "test_item"' in result[0].text + assert '"value": 42' in result[0].text From 2233b5844b104d484f4f97ac1f8a2f32a4868c87 Mon Sep 17 00:00:00 2001 From: deepak-stratforge Date: Thu, 29 May 2025 20:05:18 +0530 Subject: [PATCH 02/34] feat(tool): validate and enforce default values - Added validation to ensure all parameters listed in `exclude_args` exist in the function. - Ensured parameters in `exclude_args` have default values to avoid runtime errors. - Added test. --- src/fastmcp/tools/tool.py | 12 ++++++++++++ tests/server/test_tool_exclude_args.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index ded10ff0c..b1982c429 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -76,6 +76,18 @@ class Tool(BaseModel): if param.kind == inspect.Parameter.VAR_KEYWORD: raise ValueError("Functions with **kwargs are not supported as tools") + if exclude_args: + for arg_name in exclude_args: + if arg_name not in sig.parameters: + raise ValueError( + f"Parameter '{arg_name}' in exclude_args does not exist in function." + ) + param = sig.parameters[arg_name] + if param.default == inspect.Parameter.empty: + raise ValueError( + f"Parameter '{arg_name}' in exclude_args must have a default value." + ) + func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ if func_name == "": diff --git a/tests/server/test_tool_exclude_args.py b/tests/server/test_tool_exclude_args.py index 99e4ef571..959d2c1b6 100644 --- a/tests/server/test_tool_exclude_args.py +++ b/tests/server/test_tool_exclude_args.py @@ -1,5 +1,6 @@ from typing import Any +import pytest from mcp.types import TextContent from fastmcp import Client, FastMCP @@ -24,6 +25,21 @@ async def test_tool_exclude_args_in_tool_manager(): assert args not in tools[0].parameters +async def test_tool_exclude_args_without_default_value_raises_error(): + """Test that excluding args without default values raises ValueError""" + mcp = FastMCP("Test Server") + + with pytest.raises(ValueError): + + @mcp.tool(exclude_args=["state"]) + def echo(message: str, state: dict[str, Any] | None) -> str: + """Echo back the message provided.""" + if state: + # State was read + pass + return message + + async def test_add_tool_method_exclude_args(): """Test that tool exclude_args work with the add_tool method.""" mcp = FastMCP("Test Server") From 90180498280cbf76aa4cc180a8031fd58d994a31 Mon Sep 17 00:00:00 2001 From: Sillocan Date: Fri, 30 May 2025 16:17:06 -0700 Subject: [PATCH 03/34] test: Demonstrate issue with concurrent proxy tasks --- tests/server/test_proxy.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index f310fa505..22fe3415b 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -3,6 +3,7 @@ from typing import Any import mcp.types import pytest +from anyio import create_task_group from dirty_equals import Contains from mcp import McpError @@ -242,3 +243,26 @@ class TestPrompts: assert result.messages[0].role == "user" assert isinstance(result.messages[0].content, mcp.types.TextContent) assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" + + +async def test_proxy_handles_multiple_concurrent_tasks_correctly( + proxy_server: FastMCPProxy, +): + results = {} + + async def get_and_store(name, coro): + results[name] = await coro() + + async with create_task_group() as tg: + tg.start_soon(get_and_store, "prompts", proxy_server.get_prompts) + tg.start_soon(get_and_store, "resources", proxy_server.get_resources) + tg.start_soon(get_and_store, "tools", proxy_server.get_tools) + + assert list(results) == Contains("resources", "prompts", "tools") + assert list(results["prompts"]) == Contains("welcome") + assert [r.name for r in results["resources"].values()] == Contains( + "data://users", "resource://wave" + ) + assert list(results["tools"]) == Contains( + "greet", "add", "error_tool", "tool_without_description" + ) From 90dd0c04f35f5cbbb4aaf6df32783dbfdb259529 Mon Sep 17 00:00:00 2001 From: Sillocan Date: Fri, 30 May 2025 18:45:59 -0700 Subject: [PATCH 04/34] fix: Use a background task for managing session state mimicing stdiotransport --- src/fastmcp/client/client.py | 57 +++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 03fb7fd5a..b982c0464 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,3 +1,4 @@ +import asyncio import datetime from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path @@ -147,6 +148,10 @@ class Client(Generic[ClientTransportT]): self._session: ClientSession | None = None self._exit_stack: AsyncExitStack | None = None self._nesting_counter: int = 0 + self._context_lock = anyio.Lock() + self._session_task: asyncio.Task | None = None + self._ready_event = asyncio.Event() + self._stop_event = asyncio.Event() self._initialize_result: mcp.types.InitializeResult | None = None if log_handler is None: @@ -186,6 +191,7 @@ class Client(Generic[ClientTransportT]): self._session_kwargs["sampling_callback"] = create_sampling_callback( sampling_handler ) + # self._session_manager = self._context_manager() @property def session(self) -> ClientSession: @@ -237,34 +243,45 @@ class Client(Generic[ClientTransportT]): except TimeoutError: raise RuntimeError("Failed to initialize server session") finally: - self._exit_stack = None self._session = None self._initialize_result = None async def __aenter__(self): - if self._nesting_counter == 0: - # Create exit stack to manage both context managers - stack = AsyncExitStack() - await stack.__aenter__() - - await stack.enter_async_context(self._context_manager()) - - self._exit_stack = stack - - self._nesting_counter += 1 - + async with self._context_lock: + need_to_start = self._session_task is None or self._session_task.done() + if need_to_start: + self._stop_event = anyio.Event() + self._ready_event = anyio.Event() + self._session_task = asyncio.create_task(self._session_runner()) + await self._ready_event.wait() + self._nesting_counter += 1 return self async def __aexit__(self, exc_type, exc_val, exc_tb): - self._nesting_counter -= 1 + async with self._context_lock: + self._nesting_counter -= 1 + if self._nesting_counter != 0: + return + self._stop_event.set() + runner_task = self._session_task + self._session_task = None + if runner_task: + await runner_task + # Reset for future reconnects + self._stop_event = anyio.Event() + self._ready_event = anyio.Event() - if self._nesting_counter == 0: - # Exit the stack which will handle cleaning up the session - if self._exit_stack is not None: - try: - await self._exit_stack.__aexit__(exc_type, exc_val, exc_tb) - finally: - self._exit_stack = None + async def _session_runner(self): + async with AsyncExitStack() as stack: + try: + await stack.enter_async_context(self._context_manager()) + # Session/context is now ready + self._ready_event.set() + # Wait until disconnect/stop is requested + await self._stop_event.wait() + finally: + # On exit, ensure ready event is set (idempotent) + self._ready_event.set() async def close(self): await self.transport.close() From 10c868274beab8cd24a9283e272aad1bc57f4446 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 18:03:14 -0400 Subject: [PATCH 05/34] Ensure close() cleans up clients --- src/fastmcp/client/client.py | 52 +++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index b908fbbd7..15e72c7f1 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -150,13 +150,6 @@ class Client(Generic[ClientTransportT]): self.transport = cast(ClientTransportT, infer_transport(transport)) if auth is not None: self.transport._set_auth(auth) - self._session: ClientSession | None = None - self._exit_stack: AsyncExitStack | None = None - self._nesting_counter: int = 0 - self._context_lock = anyio.Lock() - self._session_task: asyncio.Task | None = None - self._ready_event = asyncio.Event() - self._stop_event = asyncio.Event() self._initialize_result: mcp.types.InitializeResult | None = None if log_handler is None: @@ -196,7 +189,15 @@ class Client(Generic[ClientTransportT]): self._session_kwargs["sampling_callback"] = create_sampling_callback( sampling_handler ) - # self._session_manager = self._context_manager() + + # session context management + self._session: ClientSession | None = None + self._exit_stack: AsyncExitStack | None = None + self._nesting_counter: int = 0 + self._context_lock = anyio.Lock() + self._session_task: asyncio.Task | None = None + self._ready_event = asyncio.Event() + self._stop_event = asyncio.Event() @property def session(self) -> ClientSession: @@ -252,6 +253,14 @@ class Client(Generic[ClientTransportT]): self._initialize_result = None async def __aenter__(self): + await self._connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + await self._disconnect() + + async def _connect(self): + # ensure only one session is running at a time to avoid race conditions async with self._context_lock: need_to_start = self._session_task is None or self._session_task.done() if need_to_start: @@ -262,19 +271,37 @@ class Client(Generic[ClientTransportT]): self._nesting_counter += 1 return self - async def __aexit__(self, exc_type, exc_val, exc_tb): + async def _disconnect(self, force: bool = False): + # ensure only one session is running at a time to avoid race conditions async with self._context_lock: - self._nesting_counter -= 1 - if self._nesting_counter != 0: + # if we are forcing a disconnect, reset the nesting counter + if force: + self._nesting_counter = 0 + + # otherwise decrement to check if we are done nesting + else: + self._nesting_counter = max(0, self._nesting_counter - 1) + + # if we are still nested, return + if self._nesting_counter > 0: + return + + # stop the active seesion + if self._session_task is None: return self._stop_event.set() runner_task = self._session_task self._session_task = None + + # wait for the session to finish if runner_task: await runner_task + # Reset for future reconnects self._stop_event = anyio.Event() self._ready_event = anyio.Event() + self._session = None + self._initialize_result = None async def _session_runner(self): async with AsyncExitStack() as stack: @@ -289,9 +316,8 @@ class Client(Generic[ClientTransportT]): self._ready_event.set() async def close(self): + await self._disconnect(force=True) await self.transport.close() - self._session = None - self._initialize_result = None # --- MCP Client Methods --- From 814a6e441980532aae2baa9b5ce53d46c53f8a8c Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 18:08:43 -0400 Subject: [PATCH 06/34] Add concurrency test --- tests/client/test_client.py | 46 +++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 36952f35e..b4253d306 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -342,6 +342,52 @@ async def test_client_nested_context_manager(fastmcp_server): assert client._session is None +async def test_concurrent_client_context_managers(): + """ + Test that concurrent client usage doesn't cause cross-task cancel scope issues. + https://github.com/jlowin/fastmcp/pull/643 + """ + # Create a simple server + server = FastMCP("Test Server") + + @server.tool() + def echo(text: str) -> str: + """Echo tool""" + return text + + # Create client + client = Client(server) + + # Track results + results = {} + errors = [] + + async def use_client(task_id: str, delay: float = 0): + """Use the client with a small delay to ensure overlap""" + try: + async with client: + # Add a small delay to ensure contexts overlap + await asyncio.sleep(delay) + # Make an actual call to exercise the session + tools = await client.list_tools() + results[task_id] = len(tools) + except Exception as e: + errors.append((task_id, str(e))) + + # Run multiple tasks concurrently + # The key is having them enter and exit the context at different times + await asyncio.gather( + use_client("task1", 0.0), + use_client("task2", 0.01), # Slight delay to ensure overlap + use_client("task3", 0.02), + return_exceptions=False, + ) + + assert len(errors) == 0, f"Errors occurred: {errors}" + assert len(results) == 3 + assert all(count == 1 for count in results.values()) # All should see 1 tool + + async def test_resource_template(fastmcp_server): """Test using a resource template with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) From fdfa1fe1084f5c47056d8887d63f4d9a6a0a7441 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 18:12:17 -0400 Subject: [PATCH 07/34] Use anyio.event --- src/fastmcp/client/client.py | 4 ++-- src/fastmcp/client/transports.py | 9 +++++---- tests/server/test_logging.py | 7 ++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 15e72c7f1..cc82c6eb3 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -196,8 +196,8 @@ class Client(Generic[ClientTransportT]): self._nesting_counter: int = 0 self._context_lock = anyio.Lock() self._session_task: asyncio.Task | None = None - self._ready_event = asyncio.Event() - self._stop_event = asyncio.Event() + self._ready_event = anyio.Event() + self._stop_event = anyio.Event() @property def session(self) -> ClientSession: diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 42b524485..cafe9588c 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -18,6 +18,7 @@ from typing import ( overload, ) +import anyio import httpx from mcp import ClientSession, StdioServerParameters from mcp.client.session import ( @@ -327,8 +328,8 @@ class StdioTransport(ClientTransport): self._session: ClientSession | None = None self._connect_task: asyncio.Task | None = None - self._ready_event = asyncio.Event() - self._stop_event = asyncio.Event() + self._ready_event = anyio.Event() + self._stop_event = anyio.Event() @contextlib.asynccontextmanager async def connect_session( @@ -391,8 +392,8 @@ class StdioTransport(ClientTransport): # reset variables and events for potential future reconnects self._connect_task = None - self._stop_event = asyncio.Event() - self._ready_event = asyncio.Event() + self._stop_event = anyio.Event() + self._ready_event = anyio.Event() async def close(self): await self.disconnect() diff --git a/tests/server/test_logging.py b/tests/server/test_logging.py index ea827c7f0..1a3c09a63 100644 --- a/tests/server/test_logging.py +++ b/tests/server/test_logging.py @@ -2,6 +2,7 @@ import asyncio import logging from unittest.mock import AsyncMock, Mock, patch +import anyio import pytest from fastmcp.server.server import FastMCP @@ -27,7 +28,7 @@ async def test_uvicorn_logging_default_level( """Tests that FastMCP passes log_level to uvicorn.Config if no log_config is given.""" mock_server_instance = AsyncMock() mock_uvicorn_server_constructor.return_value = mock_server_instance - serve_finished_event = asyncio.Event() + serve_finished_event = anyio.Event() mock_server_instance.serve.side_effect = serve_finished_event.wait test_log_level = "warning" @@ -63,7 +64,7 @@ async def test_uvicorn_logging_with_custom_log_config( """Tests that FastMCP passes log_config to uvicorn.Config and not log_level.""" mock_server_instance = AsyncMock() mock_uvicorn_server_constructor.return_value = mock_server_instance - serve_finished_event = asyncio.Event() + serve_finished_event = anyio.Event() mock_server_instance.serve.side_effect = serve_finished_event.wait sample_log_config = { @@ -123,7 +124,7 @@ async def test_uvicorn_logging_custom_log_config_overrides_log_level_param( """Tests log_config precedence if log_level is also passed to run_http_async.""" mock_server_instance = AsyncMock() mock_uvicorn_server_constructor.return_value = mock_server_instance - serve_finished_event = asyncio.Event() + serve_finished_event = anyio.Event() mock_server_instance.serve.side_effect = serve_finished_event.wait sample_log_config = { From 2390fb4da669df839ae6e57920a4662cfc48a234 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:32:08 -0400 Subject: [PATCH 08/34] clean up test inits --- tests/auth/__init__.py | 0 tests/cli/__init__.py | 0 tests/client/__init__.py | 1 - tests/server/http/__init__.py | 0 tests/server/openapi/__init__.py | 0 tests/server/test_lifespan.py | 396 ------------------------------- 6 files changed, 397 deletions(-) create mode 100644 tests/auth/__init__.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/server/http/__init__.py create mode 100644 tests/server/openapi/__init__.py delete mode 100644 tests/server/test_lifespan.py diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/client/__init__.py b/tests/client/__init__.py index 92836662d..e69de29bb 100644 --- a/tests/client/__init__.py +++ b/tests/client/__init__.py @@ -1 +0,0 @@ -"""Client tests package.""" diff --git a/tests/server/http/__init__.py b/tests/server/http/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/openapi/__init__.py b/tests/server/openapi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/test_lifespan.py b/tests/server/test_lifespan.py deleted file mode 100644 index ad041bbf9..000000000 --- a/tests/server/test_lifespan.py +++ /dev/null @@ -1,396 +0,0 @@ -"""Tests for lifespan functionality in both low-level and FastMCP servers.""" - -import os -import sys -import traceback -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from pathlib import Path - -import anyio -import httpx -import uvicorn -from mcp.server.lowlevel.server import NotificationOptions, Server -from mcp.server.models import InitializationOptions -from mcp.shared.message import SessionMessage -from mcp.types import ( - ClientCapabilities, - Implementation, - InitializeRequestParams, - JSONRPCMessage, - JSONRPCNotification, - JSONRPCRequest, -) -from pydantic import TypeAdapter -from starlette.applications import Starlette -from starlette.routing import Mount - -from fastmcp import Context, FastMCP -from fastmcp.utilities.tests import run_server_in_process - - -async def test_lowlevel_server_lifespan(): - """Test that lifespan works in low-level server.""" - - @asynccontextmanager - async def test_lifespan(server: Server) -> AsyncIterator[dict[str, bool]]: - """Test lifespan context that tracks startup/shutdown.""" - context = {"started": False, "shutdown": False} - try: - context["started"] = True - yield context - finally: - context["shutdown"] = True - - server = Server("test", lifespan=test_lifespan) - - # Create memory streams for testing - send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) - send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) - - # Create a tool that accesses lifespan context - @server.call_tool() - async def check_lifespan(name: str, arguments: dict) -> list: - ctx = server.request_context - assert isinstance(ctx.lifespan_context, dict) - assert ctx.lifespan_context["started"] - assert not ctx.lifespan_context["shutdown"] - return [{"type": "text", "text": "true"}] - - # Run server in background task - async with ( - anyio.create_task_group() as tg, - send_stream1, - receive_stream1, - send_stream2, - receive_stream2, - ): - - async def run_server(): - await server.run( - receive_stream1, - send_stream2, - InitializationOptions( - server_name="test", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - raise_exceptions=True, - ) - - tg.start_soon(run_server) - - # Initialize the server - params = InitializeRequestParams( - protocolVersion="2024-11-05", - capabilities=ClientCapabilities(), - clientInfo=Implementation(name="test-client", version="0.1.0"), - ) - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), - ) - ) - ) - ) - response = await receive_stream2.receive() - response = response.message - - # Send initialized notification - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) - ) - - # Call the tool to verify lifespan context - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, - ) - ) - ) - ) - - # Get response and verify - response = await receive_stream2.receive() - response = response.message - assert response.root.result["content"][0]["text"] == "true" - - # Cancel server task - tg.cancel_scope.cancel() - - -async def test_fastmcp_server_lifespan(): - """Test that lifespan works in FastMCP server.""" - - @asynccontextmanager - async def test_lifespan(server: FastMCP) -> AsyncIterator[dict]: - """Test lifespan context that tracks startup/shutdown.""" - context = {"started": False, "shutdown": False} - try: - context["started"] = True - yield context - finally: - context["shutdown"] = True - - server = FastMCP("test", lifespan=test_lifespan) - - # Create memory streams for testing - send_stream1, receive_stream1 = anyio.create_memory_object_stream(100) - send_stream2, receive_stream2 = anyio.create_memory_object_stream(100) - - # Add a tool that checks lifespan context - @server.tool() - def check_lifespan(ctx: Context) -> bool: - """Tool that checks lifespan context.""" - assert isinstance(ctx.request_context.lifespan_context, dict) - assert ctx.request_context.lifespan_context["started"] - assert not ctx.request_context.lifespan_context["shutdown"] - return True - - # Run server in background task - async with ( - anyio.create_task_group() as tg, - send_stream1, - receive_stream1, - send_stream2, - receive_stream2, - ): - - async def run_server(): - await server._mcp_server.run( - receive_stream1, - send_stream2, - server._mcp_server.create_initialization_options(), - raise_exceptions=True, - ) - - tg.start_soon(run_server) - - # Initialize the server - params = InitializeRequestParams( - protocolVersion="2024-11-05", - capabilities=ClientCapabilities(), - clientInfo=Implementation(name="test-client", version="0.1.0"), - ) - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=1, - method="initialize", - params=TypeAdapter(InitializeRequestParams).dump_python(params), - ) - ) - ) - ) - response = await receive_stream2.receive() - response = response.message - - # Send initialized notification - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCNotification( - jsonrpc="2.0", - method="notifications/initialized", - ) - ) - ) - ) - - # Call the tool to verify lifespan context - await send_stream1.send( - SessionMessage( - JSONRPCMessage( - root=JSONRPCRequest( - jsonrpc="2.0", - id=2, - method="tools/call", - params={"name": "check_lifespan", "arguments": {}}, - ) - ) - ) - ) - - # Get response and verify - response = await receive_stream2.receive() - response = response.message - assert response.root.result["content"][0]["text"] == "true" - - # Cancel server task - tg.cancel_scope.cancel() - - -def run_server_with_incorrect_lifespan_setup( - host: str, port: int, server_log_file_path: str -) -> None: - os.makedirs(os.path.dirname(server_log_file_path), exist_ok=True) - - CUSTOM_LOGGING_CONFIG = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "default": { - "()": "uvicorn.logging.DefaultFormatter", - "fmt": "%(levelprefix)s %(asctime)s [%(name)s] %(message)s", - "datefmt": "%Y-%m-%d %H:%M:%S", - "use_colors": False, - }, - "access": { - "()": "uvicorn.logging.AccessFormatter", - "fmt": '%(levelprefix)s %(asctime)s [%(name)s] %(client_addr)s - "%(request_line)s" %(status_code)s', - "datefmt": "%Y-%m-%d %H:%M:%S", - "use_colors": False, - }, - }, - "handlers": { - "file_default": { - "formatter": "default", - "class": "logging.FileHandler", - "filename": server_log_file_path, - "mode": "w", - }, - "file_access": { - "formatter": "access", - "class": "logging.FileHandler", - "filename": server_log_file_path, - "mode": "a", - }, - }, - "loggers": { - "uvicorn": { # Catches uvicorn root logs - "handlers": ["file_default"], - "level": "DEBUG", - "propagate": False, - }, - "uvicorn.error": { - "handlers": ["file_default"], - "level": "DEBUG", - "propagate": False, - }, - "uvicorn.access": { - "handlers": ["file_access"], - "level": "INFO", - "propagate": False, - }, - }, - "root": { - "handlers": ["file_default"], - "level": "DEBUG", - }, - } - - try: - mcp = FastMCP() - - @mcp.tool("ping_tool", "A simple ping tool for the test server") - def ping_tool() -> str: - return "pong" - - mcp_asgi_app = mcp.http_app(transport="streamable-http") - - parent_app = Starlette( - routes=[Mount("/mounted_mcp", app=mcp_asgi_app)], - ) - - uvicorn.run( - parent_app, - host=host, - port=port, - log_config=CUSTOM_LOGGING_CONFIG, - log_level=None, - ) - sys.exit(0) - except Exception as e_outer: - with open(server_log_file_path, "a") as f_fallback: - f_fallback.write( - "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---\n" - ) - f_fallback.write(f"{type(e_outer).__name__}: {e_outer}\n") - f_fallback.write(traceback.format_exc()) - sys.exit(1) - - -async def test_missing_lifespan_logs_informative_error(tmp_path: Path): - server_log_file = tmp_path / "server.log" - - with run_server_in_process( - run_server_with_incorrect_lifespan_setup, str(server_log_file) - ) as server_url: - full_mcp_path = server_url + "/mounted_mcp/mcp/" - - client_triggered_error = False - response_status = -1 - response_body = "" - try: - async with httpx.AsyncClient(timeout=10) as client: - response = await client.post( - full_mcp_path, - json={"id": 1, "method": "list_tools", "jsonrpc": "2.0"}, - ) - response_status = response.status_code - response_body = response.text - if response.status_code == 500: - client_triggered_error = True - else: - print( - f"Client received unexpected status code: {response.status_code} " - f"Response: {response_body[:500]}" - ) - except httpx.RequestError as e: - print(f"Client request failed with RequestError: {e}") - client_triggered_error = True - - assert client_triggered_error, ( - f"Client request did not result in a 500 error or a request error. " - f"Status: {response_status}, Body: {response_body[:500]}" - ) - - assert server_log_file.exists(), ( - f"Server log file was not created at {server_log_file}" - ) - log_content = server_log_file.read_text() - - print(f"--- Captured Server Log Content ({server_log_file}) ---") - print(log_content) - print("--- End Server Log Content ---") - - # Core assertions for the enhanced error message - assert ( - "FastMCP's StreamableHTTPSessionManager task group was not initialized" - in log_content - ) - assert "lifespan=mcp_app.lifespan" in log_content - assert "gofastmcp.com/deployment/asgi" in log_content - assert "Original error: Task group is not initialized" in log_content - - # Check for Uvicorn's own error logging wrapper for the request - assert "ERROR" in log_content # General check for ERROR level logs - assert "Exception in ASGI application" in log_content - - # Sanity checks for server operation and logging setup - assert "Uvicorn running on" in log_content - assert ( - "--- FALLBACK EXCEPTION IN SERVER RUNNER (PRE-UVICORN) ---" not in log_content - ) From ad8182eed0361925e542fb48a092747569ed96ef Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:33:50 -0400 Subject: [PATCH 09/34] Create http utility --- src/fastmcp/client/auth.py | 2 +- src/fastmcp/client/oauth_callback.py | 9 +-------- src/fastmcp/server/auth/auth.py | 10 ++++++++++ src/fastmcp/utilities/http.py | 8 ++++++++ .../test_oauth.py => auth/test_oauth_client.py} | 4 ++-- 5 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 src/fastmcp/utilities/http.py rename tests/{client/test_oauth.py => auth/test_oauth_client.py} (98%) diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index 6165fdfd8..43df9d442 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -25,9 +25,9 @@ from pydantic import AnyHttpUrl, ValidationError from fastmcp.client.oauth_callback import ( create_oauth_callback_server, - find_available_port, ) from fastmcp.settings import settings as fastmcp_global_settings +from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger __all__ = ["OAuth"] diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py index f9cecd16b..891e4cdb0 100644 --- a/src/fastmcp/client/oauth_callback.py +++ b/src/fastmcp/client/oauth_callback.py @@ -8,7 +8,6 @@ and display styled responses to users. from __future__ import annotations import asyncio -import socket from dataclasses import dataclass from starlette.applications import Starlette @@ -17,6 +16,7 @@ from starlette.responses import HTMLResponse from starlette.routing import Route from uvicorn import Config, Server +from fastmcp.utilities.http import find_available_port from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -179,13 +179,6 @@ def create_callback_html( """ -def find_available_port() -> int: - """Find an available port by letting the OS assign one.""" - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - @dataclass class CallbackResponse: code: str | None = None diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index b92160304..b5f07c523 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -23,6 +23,16 @@ class OAuthProvider( revocation_options: RevocationOptions | None = None, required_scopes: list[str] | None = None, ): + """ + Initialize the OAuth provider. + + Args: + issuer_url: The URL of the OAuth issuer. + service_documentation_url: The URL of the service documentation. + client_registration_options: The client registration options. + revocation_options: The revocation options. + required_scopes: Scopes that are required for all requests. + """ super().__init__() if isinstance(issuer_url, str): issuer_url = AnyHttpUrl(issuer_url) diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py new file mode 100644 index 000000000..22c165735 --- /dev/null +++ b/src/fastmcp/utilities/http.py @@ -0,0 +1,8 @@ +import socket + + +def find_available_port() -> int: + """Find an available port by letting the OS assign one.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] diff --git a/tests/client/test_oauth.py b/tests/auth/test_oauth_client.py similarity index 98% rename from tests/client/test_oauth.py rename to tests/auth/test_oauth_client.py index 1c2488ec8..e743e25c3 100644 --- a/tests/client/test_oauth.py +++ b/tests/auth/test_oauth_client.py @@ -11,7 +11,7 @@ import fastmcp.client.auth # Import module, not the function directly from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions -from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider +from fastmcp.server.auth.providers.in_memory import InMemory from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process @@ -20,7 +20,7 @@ def fastmcp_server(issuer_url: str): """Create a FastMCP server with OAuth authentication.""" server = FastMCP( "TestServer", - auth=InMemoryOAuthProvider( + auth=InMemory( issuer_url=issuer_url, client_registration_options=ClientRegistrationOptions(enabled=True), ), From 3d454380b15f52aa7626f6a3e8974eb7379be5a4 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 19:36:04 -0400 Subject: [PATCH 10/34] Fix import and remaining available port --- src/fastmcp/utilities/tests.py | 5 ++--- tests/auth/test_oauth_client.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index cb697129f..4fa73006e 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any, Literal import uvicorn from fastmcp.settings import settings +from fastmcp.utilities.http import find_available_port if TYPE_CHECKING: from fastmcp.server.server import FastMCP @@ -84,9 +85,7 @@ def run_server_in_process( The server URL. """ host = "127.0.0.1" - with socket.socket() as s: - s.bind((host, 0)) - port = s.getsockname()[1] + port = find_available_port() proc = multiprocessing.Process( target=server_fn, args=(host, port, *args), daemon=True diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index e743e25c3..2a1fd4c23 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -11,7 +11,7 @@ import fastmcp.client.auth # Import module, not the function directly from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions -from fastmcp.server.auth.providers.in_memory import InMemory +from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider as InMemory from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process From 97c9b9cbe44979cd7847a0e8de8fe43bf246643a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:30:36 -0400 Subject: [PATCH 11/34] Fix import --- src/fastmcp/client/transports.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index cafe9588c..5ab1d9a9e 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -37,7 +37,6 @@ from pydantic import AnyUrl from typing_extensions import Unpack from fastmcp.client.auth import OAuth -from fastmcp.server import FastMCP as FastMCPServer from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger @@ -55,7 +54,6 @@ __all__ = [ "ClientTransport", "SSETransport", "StreamableHttpTransport", - "FastMCPServer", "StdioTransport", "PythonStdioTransport", "FastMCPStdioTransport", @@ -656,7 +654,7 @@ class FastMCPTransport(ClientTransport): tests or scenarios where client and server run in the same runtime. """ - def __init__(self, mcp: FastMCPServer | FastMCP1Server): + def __init__(self, mcp: FastMCP | FastMCP1Server): """Initialize a FastMCPTransport from a FastMCP server instance.""" # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a @@ -770,7 +768,7 @@ def infer_transport(transport: ClientTransportT) -> ClientTransportT: ... @overload -def infer_transport(transport: FastMCPServer) -> FastMCPTransport: ... +def infer_transport(transport: FastMCP) -> FastMCPTransport: ... @overload @@ -805,7 +803,7 @@ def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTranspor def infer_transport( transport: ClientTransport - | FastMCPServer + | FastMCP | FastMCP1Server | AnyUrl | Path @@ -822,7 +820,7 @@ def infer_transport( The function supports these input types: - ClientTransport: Used directly without modification - - FastMCPServer or FastMCP1Server: Creates an in-memory FastMCPTransport + - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js) - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints) - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers @@ -860,7 +858,7 @@ def infer_transport( return transport # the transport is a FastMCP server (2.x or 1.0) - elif isinstance(transport, FastMCPServer | FastMCP1Server): + elif isinstance(transport, FastMCP | FastMCP1Server): inferred_transport = FastMCPTransport(mcp=transport) # the transport is a path to a script From 9cc67eeb70e895670df484e5a09112e0ac4448c2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:34:14 -0400 Subject: [PATCH 12/34] Update src/fastmcp/utilities/http.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/utilities/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py index 22c165735..c1237d62e 100644 --- a/src/fastmcp/utilities/http.py +++ b/src/fastmcp/utilities/http.py @@ -3,6 +3,6 @@ import socket def find_available_port() -> int: """Find an available port by letting the OS assign one.""" - with socket.socket() as s: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] From f1e3713fc68e0aca867ea1221fb3148090267947 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:41:18 -0400 Subject: [PATCH 13/34] Update test typing --- tests/client/test_client.py | 22 +-- tests/client/test_openapi.py | 28 ++-- tests/client/test_roots.py | 4 +- tests/client/test_sse.py | 4 +- tests/client/test_stdio.py | 28 ++-- tests/client/test_streamable_http.py | 4 +- tests/prompts/test_prompt_manager.py | 6 +- tests/resources/test_file_resources.py | 1 - tests/server/http/test_http_dependencies.py | 19 +-- tests/server/openapi/test_openapi.py | 54 ++----- tests/server/test_import_server.py | 18 +-- tests/server/test_mount.py | 38 ++--- tests/server/test_proxy.py | 24 +-- tests/server/test_server.py | 115 +++++-------- tests/server/test_server_interactions.py | 170 +++++++------------- tests/server/test_tool_annotations.py | 7 +- tests/test_examples.py | 34 +--- tests/tools/test_tool.py | 46 ++---- tests/tools/test_tool_manager.py | 111 +++---------- tests/utilities/test_mcp_config.py | 8 +- 20 files changed, 221 insertions(+), 520 deletions(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index b4253d306..d1398640d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -16,7 +16,6 @@ from fastmcp.client.transports import ( infer_transport, ) from fastmcp.exceptions import ResourceError, ToolError -from fastmcp.prompts.prompt import TextContent from fastmcp.server.server import FastMCP @@ -201,8 +200,7 @@ async def test_get_prompt(fastmcp_server): result = await client.get_prompt("welcome", {"name": "Developer"}) # The result should contain our welcome message - assert isinstance(result.messages[0].content, TextContent) - assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" + assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined] assert result.description == "Example greeting prompt." @@ -214,8 +212,7 @@ async def test_get_prompt_mcp(fastmcp_server): result = await client.get_prompt_mcp("welcome", {"name": "Developer"}) # The result should contain our welcome message - assert isinstance(result.messages[0].content, TextContent) - assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" + assert result.messages[0].content.text == "Welcome to FastMCP, Developer!" # type: ignore[attr-defined] assert result.description == "Example greeting prompt." @@ -522,9 +519,8 @@ class TestErrorHandling: async with client: result = await client.call_tool_mcp("error_tool", {}) assert result.isError - assert isinstance(result.content[0], TextContent) - assert "test error" in result.content[0].text - assert "abc" in result.content[0].text + assert "test error" in result.content[0].text # type: ignore[attr-defined] + assert "abc" in result.content[0].text # type: ignore[attr-defined] async def test_general_tool_exceptions_are_masked_when_enabled(self): mcp = FastMCP("TestServer", mask_error_details=True) @@ -538,9 +534,8 @@ class TestErrorHandling: async with client: result = await client.call_tool_mcp("error_tool", {}) assert result.isError - assert isinstance(result.content[0], TextContent) - assert "test error" not in result.content[0].text - assert "abc" not in result.content[0].text + assert "test error" not in result.content[0].text # type: ignore[attr-defined] + assert "abc" not in result.content[0].text # type: ignore[attr-defined] async def test_specific_tool_errors_are_sent_to_client(self): mcp = FastMCP("TestServer") @@ -554,9 +549,8 @@ class TestErrorHandling: async with client: result = await client.call_tool_mcp("custom_error_tool", {}) assert result.isError - assert isinstance(result.content[0], TextContent) - assert "test error" in result.content[0].text - assert "abc" in result.content[0].text + assert "test error" in result.content[0].text # type: ignore[attr-defined] + assert "abc" in result.content[0].text # type: ignore[attr-defined] async def test_general_resource_exceptions_are_not_masked_by_default(self): mcp = FastMCP("TestServer") diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 0a05bb00d..8ea642096 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -5,7 +5,6 @@ from collections.abc import Generator import pytest import uvicorn from fastapi import FastAPI, Request -from mcp.types import TextContent, TextResourceContents from fastmcp import Client, FastMCP from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -111,8 +110,7 @@ class TestClientHeaders: transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) ) as client: result = await client.read_resource("resource://get_headers_headers_get") - assert isinstance(result[0], TextResourceContents) - headers = json.loads(result[0].text) + headers = json.loads(result[0].text) # type: ignore[attr-defined] assert headers["x-test"] == "test-123" async def test_client_headers_shttp_resource(self, shttp_server: str): @@ -122,8 +120,7 @@ class TestClientHeaders: ) ) as client: result = await client.read_resource("resource://get_headers_headers_get") - assert isinstance(result[0], TextResourceContents) - headers = json.loads(result[0].text) + headers = json.loads(result[0].text) # type: ignore[attr-defined] assert headers["x-test"] == "test-123" async def test_client_headers_sse_resource_template(self, sse_server: str): @@ -133,8 +130,7 @@ class TestClientHeaders: result = await client.read_resource( "resource://get_header_by_name_headers/x-test" ) - assert isinstance(result[0], TextResourceContents) - header = json.loads(result[0].text) + header = json.loads(result[0].text) # type: ignore[attr-defined] assert header == "test-123" async def test_client_headers_shttp_resource_template(self, shttp_server: str): @@ -146,8 +142,7 @@ class TestClientHeaders: result = await client.read_resource( "resource://get_header_by_name_headers/x-test" ) - assert isinstance(result[0], TextResourceContents) - header = json.loads(result[0].text) + header = json.loads(result[0].text) # type: ignore[attr-defined] assert header == "test-123" async def test_client_headers_sse_tool(self, sse_server: str): @@ -155,8 +150,7 @@ class TestClientHeaders: transport=SSETransport(sse_server, headers={"X-TEST": "test-123"}) ) as client: result = await client.call_tool("post_headers_headers_post") - assert isinstance(result[0], TextContent) - headers = json.loads(result[0].text) + headers = json.loads(result[0].text) # type: ignore[attr-defined] assert headers["x-test"] == "test-123" async def test_client_headers_shttp_tool(self, shttp_server: str): @@ -166,8 +160,7 @@ class TestClientHeaders: ) ) as client: result = await client.call_tool("post_headers_headers_post") - assert isinstance(result[0], TextContent) - headers = json.loads(result[0].text) + headers = json.loads(result[0].text) # type: ignore[attr-defined] assert headers["x-test"] == "test-123" async def test_client_overrides_server_headers(self, shttp_server: str): @@ -177,8 +170,7 @@ class TestClientHeaders: ) ) as client: result = await client.read_resource("resource://get_headers_headers_get") - assert isinstance(result[0], TextResourceContents) - headers = json.loads(result[0].text) + headers = json.loads(result[0].text) # type: ignore[attr-defined] assert headers["x-server-header"] == "test-client" async def test_client_with_excluded_header_is_ignored(self, sse_server: str): @@ -193,8 +185,7 @@ class TestClientHeaders: ) ) as client: result = await client.read_resource("resource://get_headers_headers_get") - assert isinstance(result[0], TextResourceContents) - headers = json.loads(result[0].text) + headers = json.loads(result[0].text) # type: ignore[attr-defined] assert headers["not-host"] == "1.2.3.4" assert headers["host"] == "fastapi" @@ -204,6 +195,5 @@ class TestClientHeaders: """ async with Client(transport=StreamableHttpTransport(proxy_server)) as client: result = await client.read_resource("resource://get_headers_headers_get") - assert isinstance(result[0], TextResourceContents) - headers = json.loads(result[0].text) + headers = json.loads(result[0].text) # type: ignore[attr-defined] assert headers["x-server-header"] == "test-abc" diff --git a/tests/client/test_roots.py b/tests/client/test_roots.py index 74b478a6d..91739aa6b 100644 --- a/tests/client/test_roots.py +++ b/tests/client/test_roots.py @@ -1,7 +1,6 @@ import json import pytest -from mcp.types import TextContent from fastmcp import Client, Context, FastMCP @@ -41,8 +40,7 @@ class TestClientRoots: async def test_valid_roots(self, fastmcp_server: FastMCP, roots: list[str]): async with Client(fastmcp_server, roots=roots) as client: result = await client.call_tool("list_roots", {}) - assert isinstance(result[0], TextContent) - assert json.loads(result[0].text) == [ + assert json.loads(result[0].text) == [ # type: ignore[attr-defined] "file://x/y/z", "file://x/y/z", ] diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index 787657882..ebf7a0a31 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -6,7 +6,6 @@ from collections.abc import Generator import pytest import uvicorn from mcp import McpError -from mcp.types import TextResourceContents from starlette.applications import Starlette from starlette.routing import Mount @@ -96,8 +95,7 @@ async def test_http_headers(sse_server: str): transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) ) as client: raw_result = await client.read_resource("request://headers") - assert isinstance(raw_result[0], TextResourceContents) - json_result = json.loads(raw_result[0].text) + json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 71bbefe4a..c32975b48 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -1,7 +1,6 @@ import inspect import pytest -from mcp.types import TextContent from fastmcp import Client from fastmcp.client.transports import PythonStdioTransport, StdioTransport @@ -49,13 +48,11 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - assert isinstance(result1[0], TextContent) - pid1 = int(result1[0].text) + pid1 = int(result1[0].text) # type: ignore[attr-defined] async with client: result2 = await client.call_tool("pid") - assert isinstance(result2[0], TextContent) - pid2 = int(result2[0].text) + pid2 = int(result2[0].text) # type: ignore[attr-defined] assert pid1 == pid2 @@ -69,13 +66,11 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - assert isinstance(result1[0], TextContent) - pid1 = int(result1[0].text) + pid1 = int(result1[0].text) # type: ignore[attr-defined] async with client: result2 = await client.call_tool("pid") - assert isinstance(result2[0], TextContent) - pid2 = int(result2[0].text) + pid2 = int(result2[0].text) # type: ignore[attr-defined] assert pid1 != pid2 @@ -85,15 +80,13 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - assert isinstance(result1[0], TextContent) - pid1 = int(result1[0].text) + pid1 = int(result1[0].text) # type: ignore[attr-defined] await client.close() async with client: result2 = await client.call_tool("pid") - assert isinstance(result2[0], TextContent) - pid2 = int(result2[0].text) + pid2 = int(result2[0].text) # type: ignore[attr-defined] assert pid1 != pid2 @@ -103,17 +96,14 @@ class TestKeepAlive: async with client: result1 = await client.call_tool("pid") - assert isinstance(result1[0], TextContent) - pid1 = int(result1[0].text) + pid1 = int(result1[0].text) # type: ignore[attr-defined] async with client: result2 = await client.call_tool("pid") - assert isinstance(result2[0], TextContent) - pid2 = int(result2[0].text) + pid2 = int(result2[0].text) # type: ignore[attr-defined] result3 = await client.call_tool("pid") - assert isinstance(result3[0], TextContent) - pid3 = int(result3[0].text) + pid3 = int(result3[0].text) # type: ignore[attr-defined] assert pid1 == pid2 == pid3 diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 53e765242..34723e7c4 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -6,7 +6,6 @@ from collections.abc import Generator import pytest import uvicorn from mcp import McpError -from mcp.types import TextResourceContents from starlette.applications import Starlette from starlette.routing import Mount @@ -106,8 +105,7 @@ async def test_http_headers(streamable_http_server: str): ) ) as client: raw_result = await client.read_resource("request://headers") - assert isinstance(raw_result[0], TextResourceContents) - json_result = json.loads(raw_result[0].text) + json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index e00aba3e0..51710792c 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -393,8 +393,7 @@ class TestContextHandling: messages = await prompt.render(arguments={"x": 42}) assert len(messages) == 1 - assert isinstance(messages[0].content, TextContent) - assert messages[0].content.text == "42" + assert messages[0].content.text == "42" # type: ignore[attr-defined] async def test_context_optional(self): """Test that context is optional when rendering prompts.""" @@ -416,8 +415,7 @@ class TestContextHandling: ) assert len(messages) == 1 - assert isinstance(messages[0].content, TextContent) - assert messages[0].content.text == "42" + assert messages[0].content.text == "42" # type: ignore[attr-defined] async def test_annotated_context_parameter_detection(self): """Test that annotated context parameters are properly detected in diff --git a/tests/resources/test_file_resources.py b/tests/resources/test_file_resources.py index 5f355e360..05ba0fe75 100644 --- a/tests/resources/test_file_resources.py +++ b/tests/resources/test_file_resources.py @@ -74,7 +74,6 @@ class TestFileResource: is_binary=True, ) content = await resource.read() - assert isinstance(content, bytes) assert content == b"test content" def test_relative_path_error(self): diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 192090792..938b55c14 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -4,7 +4,6 @@ from collections.abc import Generator import pytest import uvicorn -from mcp.types import TextContent, TextResourceContents from fastmcp.client import Client from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -99,8 +98,7 @@ async def test_http_headers_resource_shttp(shttp_server: str): ) ) as client: raw_result = await client.read_resource("request://headers") - assert isinstance(raw_result[0], TextResourceContents) - json_result = json.loads(raw_result[0].text) + json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" @@ -111,8 +109,7 @@ async def test_http_headers_resource_sse(sse_server: str): transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) ) as client: raw_result = await client.read_resource("request://headers") - assert isinstance(raw_result[0], TextResourceContents) - json_result = json.loads(raw_result[0].text) + json_result = json.loads(raw_result[0].text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" @@ -125,8 +122,7 @@ async def test_http_headers_tool_shttp(shttp_server: str): ) ) as client: result = await client.call_tool("get_headers_tool") - assert isinstance(result[0], TextContent) - json_result = json.loads(result[0].text) + json_result = json.loads(result[0].text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" @@ -136,8 +132,7 @@ async def test_http_headers_tool_sse(sse_server: str): transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) ) as client: result = await client.call_tool("get_headers_tool") - assert isinstance(result[0], TextContent) - json_result = json.loads(result[0].text) + json_result = json.loads(result[0].text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" @@ -150,8 +145,7 @@ async def test_http_headers_prompt_shttp(shttp_server: str): ) ) as client: result = await client.get_prompt("get_headers_prompt") - assert isinstance(result.messages[0].content, TextContent) - json_result = json.loads(result.messages[0].content.text) + json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" @@ -162,7 +156,6 @@ async def test_http_headers_prompt_sse(sse_server: str): transport=SSETransport(sse_server, headers={"X-DEMO-HEADER": "ABC"}) ) as client: result = await client.get_prompt("get_headers_prompt") - assert isinstance(result.messages[0].content, TextContent) - json_result = json.loads(result.messages[0].content.text) + json_result = json.loads(result.messages[0].content.text) # type: ignore[attr-defined] assert "x-demo-header" in json_result assert json_result["x-demo-header"] == "ABC" diff --git a/tests/server/openapi/test_openapi.py b/tests/server/openapi/test_openapi.py index f07f6c87c..97b5297a6 100644 --- a/tests/server/openapi/test_openapi.py +++ b/tests/server/openapi/test_openapi.py @@ -9,7 +9,7 @@ from dirty_equals import IsStr from fastapi import FastAPI, HTTPException, Response from fastapi.responses import PlainTextResponse from httpx import ASGITransport, AsyncClient -from mcp.types import BlobResourceContents, TextContent, TextResourceContents +from mcp.types import BlobResourceContents from pydantic import BaseModel, TypeAdapter from pydantic.networks import AnyUrl @@ -234,11 +234,7 @@ class TestTools: "create_user_users_post", {"name": "David", "active": False} ) - # Convert TextContent to dict for comparison - assert isinstance(tool_response, list) and len(tool_response) == 1 - assert isinstance(tool_response[0], TextContent) - - response_data = json.loads(tool_response[0].text) + response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined] expected_user = User(id=4, name="David", active=False).model_dump() assert response_data == expected_user @@ -249,8 +245,7 @@ class TestTools: # Check that the user was created via MCP async with Client(fastmcp_openapi_server) as client: user_response = await client.read_resource("resource://get_user_users/4") - assert isinstance(user_response[0], TextResourceContents) - response_text = user_response[0].text + response_text = user_response[0].text # type: ignore[attr-defined] user = json.loads(response_text) assert user == expected_user @@ -266,11 +261,7 @@ class TestTools: {"user_id": 1, "name": "XYZ"}, ) - # Convert TextContent to dict for comparison - assert isinstance(tool_response, list) and len(tool_response) == 1 - assert isinstance(tool_response[0], TextContent) - - response_data = json.loads(tool_response[0].text) + response_data = json.loads(tool_response[0].text) # type: ignore[attr-defined] expected_data = dict(id=1, name="XYZ", active=True) assert response_data == expected_data @@ -281,8 +272,7 @@ class TestTools: # Check that the user was updated via MCP async with Client(fastmcp_openapi_server) as client: user_response = await client.read_resource("resource://get_user_users/1") - assert isinstance(user_response[0], TextResourceContents) - response_text = user_response[0].text + response_text = user_response[0].text # type: ignore[attr-defined] user = json.loads(response_text) assert user == expected_data @@ -305,9 +295,7 @@ class TestTools: ) async with Client(mcp_server) as client: tool_response = await client.call_tool("get_users_users_get", {}) - assert isinstance(tool_response, list) - assert isinstance(tool_response[0], TextContent) - assert json.loads(tool_response[0].text) == [ + assert json.loads(tool_response[0].text) == [ # type: ignore[attr-defined] user.model_dump() for user in sorted(users_db.values(), key=lambda x: x.id) ] @@ -341,8 +329,7 @@ class TestResources: resource_response = await client.read_resource( "resource://get_users_users_get" ) - assert isinstance(resource_response[0], TextResourceContents) - response_text = resource_response[0].text + response_text = resource_response[0].text # type: ignore[attr-defined] resource = json.loads(response_text) assert resource == json_users response = await api_client.get("/users") @@ -369,8 +356,7 @@ class TestResources: """Test reading a resource that returns a string.""" async with Client(fastmcp_openapi_server) as client: resource_response = await client.read_resource("resource://ping_ping_get") - assert isinstance(resource_response[0], TextResourceContents) - assert resource_response[0].text == "pong" + assert resource_response[0].text == "pong" # type: ignore[attr-defined] class TestResourceTemplates: @@ -407,8 +393,7 @@ class TestResourceTemplates: resource_response = await client.read_resource( f"resource://get_user_users/{user_id}" ) - assert isinstance(resource_response[0], TextResourceContents) - response_text = resource_response[0].text + response_text = resource_response[0].text # type: ignore[attr-defined] resource = json.loads(response_text) assert resource == users_db[user_id].model_dump() @@ -430,8 +415,7 @@ class TestResourceTemplates: resource_response = await client.read_resource( f"resource://get_user_active_state_users/{is_active}/{user_id}" ) - assert isinstance(resource_response[0], TextResourceContents) - response_text = resource_response[0].text + response_text = resource_response[0].text # type: ignore[attr-defined] resource = json.loads(response_text) assert resource == users_db[user_id].model_dump() @@ -681,8 +665,7 @@ class TestOpenAPI30Compatibility: """Test reading a resource from an OpenAPI 3.0 server.""" async with Client(openapi_30_server) as client: resource_response = await client.read_resource("resource://listProducts") - assert isinstance(resource_response[0], TextResourceContents) - response_text = resource_response[0].text + response_text = resource_response[0].text # type: ignore[attr-defined] content = json.loads(response_text) assert len(content) == 2 assert content[0]["name"] == "Product 1" @@ -692,8 +675,7 @@ class TestOpenAPI30Compatibility: """Test reading a resource from template from an OpenAPI 3.0 server.""" async with Client(openapi_30_server) as client: resource_response = await client.read_resource("resource://getProduct/p1") - assert isinstance(resource_response[0], TextResourceContents) - response_text = resource_response[0].text + response_text = resource_response[0].text # type: ignore[attr-defined] content = json.loads(response_text) assert content["id"] == "p1" assert content["name"] == "Product 1" @@ -707,8 +689,7 @@ class TestOpenAPI30Compatibility: ) # Result should be a text content assert len(result) == 1 - assert isinstance(result[0], TextContent) - product = json.loads(result[0].text) + product = json.loads(result[0].text) # type: ignore[attr-defined] assert product["id"] == "p3" assert product["name"] == "New Product" assert product["price"] == 39.99 @@ -857,8 +838,7 @@ class TestOpenAPI31Compatibility: """Test reading a resource from an OpenAPI 3.1 server.""" async with Client(openapi_31_server) as client: resource_response = await client.read_resource("resource://listOrders") - assert isinstance(resource_response[0], TextResourceContents) - response_text = resource_response[0].text + response_text = resource_response[0].text # type: ignore[attr-defined] content = json.loads(response_text) assert len(content) == 2 assert content[0]["customer"] == "Alice" @@ -868,8 +848,7 @@ class TestOpenAPI31Compatibility: """Test reading a resource from template from an OpenAPI 3.1 server.""" async with Client(openapi_31_server) as client: resource_response = await client.read_resource("resource://getOrder/o1") - assert isinstance(resource_response[0], TextResourceContents) - response_text = resource_response[0].text + response_text = resource_response[0].text # type: ignore[attr-defined] content = json.loads(response_text) assert content["id"] == "o1" assert content["customer"] == "Alice" @@ -883,8 +862,7 @@ class TestOpenAPI31Compatibility: ) # Result should be a text content assert len(result) == 1 - assert isinstance(result[0], TextContent) - order = json.loads(result[0].text) + order = json.loads(result[0].text) # type: ignore[attr-dict] assert order["id"] == "o3" assert order["customer"] == "Charlie" assert order["items"] == ["item4", "item5"] diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 93512f23d..ed03567be 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -1,8 +1,6 @@ import json from urllib.parse import quote -from mcp.types import TextContent, TextResourceContents - from fastmcp.client.client import Client from fastmcp.server.server import FastMCP @@ -223,8 +221,7 @@ async def test_call_imported_custom_named_tool(): async with Client(main_app) as client: result = await client.call_tool("api_get_data", {"query": "test"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Data for query: test" + assert result[0].text == "Data for query: test" # type: ignore[attr-defined] async def test_first_level_importing_with_custom_name(): @@ -278,8 +275,7 @@ async def test_call_nested_imported_tool(): result = await main_app._tool_manager.call_tool( "service_provider_compute", {"input": 21} ) - assert isinstance(result[0], TextContent) - assert result[0].text == "42" + assert result[0].text == "42" # type: ignore[attr-defined] async def test_import_with_proxy_tools(): @@ -302,8 +298,7 @@ async def test_import_with_proxy_tools(): await main_app.import_server("api", proxy_app) result = await main_app._mcp_call_tool("api_get_data", {"query": "test"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Data for query: test" + assert result[0].text == "Data for query: test" # type: ignore[attr-defined] async def test_import_with_proxy_prompts(): @@ -326,7 +321,6 @@ async def test_import_with_proxy_prompts(): await main_app.import_server("api", proxy_app) result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"}) - assert isinstance(result.messages[0].content, TextContent) assert result.messages[0].content.text == "Hello, World from API!" assert result.description == "Example greeting prompt." @@ -356,8 +350,7 @@ async def test_import_with_proxy_resources(): # Access the resource through the main app with the prefixed key async with Client(main_app) as client: result = await client.read_resource("config://api/settings") - assert isinstance(result[0], TextResourceContents) - content = json.loads(result[0].text) + content = json.loads(result[0].text) # type: ignore[attr-defined] assert content["api_key"] == "12345" assert content["base_url"] == "https://api.example.com" @@ -387,8 +380,7 @@ async def test_import_with_proxy_resource_templates(): quoted_email = quote("john@example.com", safe="") async with Client(main_app) as client: result = await client.read_resource(f"user://api/{quoted_name}/{quoted_email}") - assert isinstance(result[0], TextResourceContents) - content = json.loads(result[0].text) + content = json.loads(result[0].text) # type: ignore[attr-defined] assert content["name"] == "John Doe" assert content["email"] == "john@example.com" diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index a04a14edf..17fdf3a68 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -3,8 +3,6 @@ import sys from contextlib import asynccontextmanager import pytest -from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.types import TextContent, TextResourceContents from fastmcp import FastMCP from fastmcp.client import Client @@ -36,8 +34,7 @@ class TestBasicMount: async with Client(main_app) as client: result = await client.call_tool("sub_sub_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == "This is from the sub app" + assert result[0].text == "This is from the sub app" # type: ignore[attr-defined] async def test_mount_with_custom_separator(self): """Test mounting with a custom tool separator (deprecated but still supported).""" @@ -57,8 +54,7 @@ class TestBasicMount: # Call the tool result = await main_app._mcp_call_tool("sub_greet", {"name": "World"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Hello, World!" + assert result[0].text == "Hello, World!" # type: ignore[attr-defined] async def test_mount_invalid_resource_prefix(self): main_app = FastMCP("MainApp") @@ -147,12 +143,10 @@ class TestMultipleServerMount: # Call tools from both mounted servers result1 = await main_app._mcp_call_tool("weather_get_forecast", {}) - assert isinstance(result1[0], TextContent) - assert result1[0].text == "Weather forecast" + assert result1[0].text == "Weather forecast" # type: ignore[attr-defined] result2 = await main_app._mcp_call_tool("news_get_headlines", {}) - assert isinstance(result2[0], TextContent) - assert result2[0].text == "News headlines" + assert result2[0].text == "News headlines" # type: ignore[attr-defined] async def test_mount_same_prefix(self): """Test that mounting with the same prefix replaces the previous mount.""" @@ -227,8 +221,7 @@ class TestMultipleServerMount: # Test calling a tool result = await client.call_tool("working_working_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Working tool" + assert result[0].text == "Working tool" # type: ignore[attr-defined] # Test resources resources = await client.list_resources() @@ -284,8 +277,7 @@ class TestDynamicChanges: # Call the dynamically added tool result = await main_app._mcp_call_tool("sub_dynamic_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Added after mounting" + assert result[0].text == "Added after mounting" # type: ignore[attr-defined] async def test_removing_tool_after_mounting(self): """Test that tools removed from mounted servers are no longer accessible.""" @@ -335,8 +327,7 @@ class TestResourcesAndTemplates: # Check that resource can be accessed async with Client(main_app) as client: result = await client.read_resource("data://data/users") - assert isinstance(result[0], TextResourceContents) - assert json.loads(result[0].text) == ["user1", "user2"] + assert json.loads(result[0].text) == ["user1", "user2"] # type: ignore[attr-defined] async def test_mount_with_resource_templates(self): """Test mounting a server with resource templates.""" @@ -357,8 +348,7 @@ class TestResourcesAndTemplates: # Check template instantiation async with Client(main_app) as client: result = await client.read_resource("users://api/123/profile") - assert isinstance(result[0], TextResourceContents) - profile = json.loads(result[0].text) + profile = json.loads(result[0].text) # type: ignore assert profile["id"] == "123" assert profile["name"] == "User 123" @@ -382,8 +372,7 @@ class TestResourcesAndTemplates: # Check access to the resource async with Client(main_app) as client: result = await client.read_resource("data://data/config") - assert isinstance(result[0], TextResourceContents) - config = json.loads(result[0].text) + config = json.loads(result[0].text) # type: ignore[attr-defined] assert config["version"] == "1.0" @@ -461,8 +450,7 @@ class TestProxyServer: # Call the tool result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Data for test" + assert result[0].text == "Data for test" # type: ignore[attr-defined] async def test_dynamically_adding_to_proxied_server(self): """Test that changes to the original server are reflected in the mounted proxy.""" @@ -489,8 +477,7 @@ class TestProxyServer: # Call the tool result = await main_app._mcp_call_tool("proxy_dynamic_data", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Dynamic data" + assert result[0].text == "Dynamic data" # type: ignore[attr-defined] async def test_proxy_server_with_resources(self): """Test mounting a proxy server with resources.""" @@ -512,8 +499,7 @@ class TestProxyServer: # Resource should be accessible through main app result = await main_app._mcp_read_resource("config://proxy/settings") - assert isinstance(result[0], ReadResourceContents) - config = json.loads(result[0].content) + config = json.loads(result[0].content) # type: ignore[attr-defined] assert config["api_key"] == "12345" async def test_proxy_server_with_prompts(self): diff --git a/tests/server/test_proxy.py b/tests/server/test_proxy.py index 22fe3415b..12f568047 100644 --- a/tests/server/test_proxy.py +++ b/tests/server/test_proxy.py @@ -1,7 +1,6 @@ import json from typing import Any -import mcp.types import pytest from anyio import create_task_group from dirty_equals import Contains @@ -90,16 +89,14 @@ async def test_as_proxy_with_server(fastmcp_server): """FastMCP.as_proxy should accept a FastMCP instance.""" proxy = FastMCP.as_proxy(fastmcp_server) result = await proxy._mcp_call_tool("greet", {"name": "Test"}) - assert isinstance(result[0], mcp.types.TextContent) - assert result[0].text == "Hello, Test!" + assert result[0].text == "Hello, Test!" # type: ignore[attr-defined] async def test_as_proxy_with_transport(fastmcp_server): """FastMCP.as_proxy should accept a ClientTransport.""" proxy = FastMCP.as_proxy(FastMCPTransport(fastmcp_server)) result = await proxy._mcp_call_tool("greet", {"name": "Test"}) - assert isinstance(result[0], mcp.types.TextContent) - assert result[0].text == "Hello, Test!" + assert result[0].text == "Hello, Test!" # type: ignore[attr-defined] def test_as_proxy_with_url(): @@ -138,9 +135,7 @@ class TestTools: async def test_call_tool_calls_tool(self, proxy_server): async with Client(proxy_server) as client: proxy_result = await client.call_tool("add", {"a": 1, "b": 2}) - - assert isinstance(proxy_result[0], mcp.types.TextContent) - assert proxy_result[0].text == "3" + assert proxy_result[0].text == "3" # type: ignore[attr-defined] async def test_error_tool_raises_error(self, proxy_server): with pytest.raises(ToolError, match=""): @@ -164,8 +159,7 @@ class TestResources: async def test_read_resource(self, proxy_server: FastMCPProxy): async with Client(proxy_server) as client: result = await client.read_resource("resource://wave") - assert isinstance(result[0], mcp.types.TextResourceContents) - assert result[0].text == "👋" + assert result[0].text == "👋" # type: ignore[attr-defined] async def test_read_resource_same_as_original(self, fastmcp_server, proxy_server): async with Client(fastmcp_server) as client: @@ -177,8 +171,7 @@ class TestResources: async def test_read_json_resource(self, proxy_server: FastMCPProxy): async with Client(proxy_server) as client: result = await client.read_resource("data://users") - assert isinstance(result[0], mcp.types.TextResourceContents) - assert json.loads(result[0].text) == USERS + assert json.loads(result[0].text) == USERS # type: ignore[attr-defined] async def test_read_resource_returns_none_if_not_found(self, proxy_server): with pytest.raises(McpError, match="Unknown resource: resource://nonexistent"): @@ -202,8 +195,7 @@ class TestResourceTemplates: async def test_read_resource_template(self, proxy_server: FastMCPProxy, id: int): async with Client(proxy_server) as client: result = await client.read_resource(f"data://user/{id}") - assert isinstance(result[0], mcp.types.TextResourceContents) - assert json.loads(result[0].text) == USERS[id - 1] + assert json.loads(result[0].text) == USERS[id - 1] # type: ignore[attr-defined] async def test_read_resource_template_same_as_original( self, fastmcp_server, proxy_server @@ -239,10 +231,8 @@ class TestPrompts: async def test_render_prompt_calls_prompt(self, proxy_server): async with Client(proxy_server) as client: result = await client.get_prompt("welcome", {"name": "Alice"}) - assert isinstance(result.messages[0], mcp.types.PromptMessage) assert result.messages[0].role == "user" - assert isinstance(result.messages[0].content, mcp.types.TextContent) - assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" + assert result.messages[0].content.text == "Welcome to FastMCP, Alice!" # type: ignore[attr-defined] async def test_proxy_handles_multiple_concurrent_tasks_correctly( diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 1f420213c..56ae434ba 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -2,10 +2,6 @@ from typing import Annotated import pytest from mcp import McpError -from mcp.types import ( - TextContent, - TextResourceContents, -) from pydantic import Field from fastmcp import Client, FastMCP @@ -48,8 +44,7 @@ class TestCreateServer: result = await client.call_tool("hello_world", {}) assert len(result) == 1 content = result[0] - assert isinstance(content, TextContent) - assert "¡Hola, 世界! 👋" == content.text + assert content.text == "¡Hola, 世界! 👋" # type: ignore[attr-defined] class TestTools: @@ -114,8 +109,7 @@ class TestToolDecorator: return x + y result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async def test_tool_decorator_incorrect_usage(self): mcp = FastMCP() @@ -134,8 +128,7 @@ class TestToolDecorator: return x + y result = await mcp._mcp_call_tool("custom-add", {"x": 1, "y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async def test_tool_decorator_with_description(self): mcp = FastMCP() @@ -163,8 +156,7 @@ class TestToolDecorator: obj = MyClass(10) mcp.add_tool(obj.add) result = await mcp._mcp_call_tool("add", {"y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "12" + assert result[0].text == "12" # type: ignore[attr-defined] async def test_tool_decorator_classmethod(self): mcp = FastMCP() @@ -178,8 +170,7 @@ class TestToolDecorator: mcp.add_tool(MyClass.add) result = await mcp._mcp_call_tool("add", {"y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "12" + assert result[0].text == "12" # type: ignore[attr-defined] async def test_tool_decorator_staticmethod(self): mcp = FastMCP() @@ -191,8 +182,7 @@ class TestToolDecorator: return x + y result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async def test_tool_decorator_async_function(self): mcp = FastMCP() @@ -202,8 +192,7 @@ class TestToolDecorator: return x + y result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async def test_tool_decorator_classmethod_async_function(self): mcp = FastMCP() @@ -217,8 +206,7 @@ class TestToolDecorator: mcp.add_tool(MyClass.add) result = await mcp._mcp_call_tool("add", {"y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "12" + assert result[0].text == "12" # type: ignore[attr-defined] async def test_tool_decorator_staticmethod_async_function(self): mcp = FastMCP() @@ -230,8 +218,7 @@ class TestToolDecorator: mcp.add_tool(MyClass.add) result = await mcp._mcp_call_tool("add", {"x": 1, "y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async def test_tool_decorator_with_tags(self): """Test that the tool decorator properly sets tags.""" @@ -262,8 +249,7 @@ class TestToolDecorator: # Call the tool by its custom name result = await mcp._mcp_call_tool("custom_multiply", {"a": 5, "b": 3}) - assert isinstance(result[0], TextContent) - assert result[0].text == "15" + assert result[0].text == "15" # type: ignore[attr-defined] # Original name should not be registered assert "multiply" not in tools @@ -316,8 +302,7 @@ class TestResourceDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Hello, world!" + assert result[0].text == "Hello, world!" # type: ignore[attr-defined] async def test_resource_decorator_incorrect_usage(self): mcp = FastMCP() @@ -344,8 +329,7 @@ class TestResourceDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Hello, world!" + assert result[0].text == "Hello, world!" # type: ignore[attr-defined] async def test_resource_decorator_with_description(self): mcp = FastMCP() @@ -389,8 +373,7 @@ class TestResourceDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "My prefix: Hello, world!" + assert result[0].text == "My prefix: Hello, world!" # type: ignore[attr-defined] async def test_resource_decorator_classmethod(self): mcp = FastMCP() @@ -408,8 +391,7 @@ class TestResourceDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Class prefix: Hello, world!" + assert result[0].text == "Class prefix: Hello, world!" # type: ignore[attr-defined] async def test_resource_decorator_staticmethod(self): mcp = FastMCP() @@ -422,8 +404,7 @@ class TestResourceDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Static Hello, world!" + assert result[0].text == "Static Hello, world!" # type: ignore[attr-defined] async def test_resource_decorator_async_function(self): mcp = FastMCP() @@ -434,8 +415,7 @@ class TestResourceDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Async Hello, world!" + assert result[0].text == "Async Hello, world!" # type: ignore[attr-defined] class TestTemplateDecorator: @@ -454,8 +434,7 @@ class TestTemplateDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://test/data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Data for test" + assert result[0].text == "Data for test" # type: ignore[attr-defined] async def test_template_decorator_incorrect_usage(self): mcp = FastMCP() @@ -482,8 +461,7 @@ class TestTemplateDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://test/data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Data for test" + assert result[0].text == "Data for test" # type: ignore[attr-defined] async def test_template_decorator_with_description(self): mcp = FastMCP() @@ -514,8 +492,7 @@ class TestTemplateDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://test/data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "My prefix: Data for test" + assert result[0].text == "My prefix: Data for test" # type: ignore[attr-defined] async def test_template_decorator_classmethod(self): mcp = FastMCP() @@ -535,8 +512,7 @@ class TestTemplateDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://test/data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Class prefix: Data for test" + assert result[0].text == "Class prefix: Data for test" # type: ignore[attr-defined] async def test_template_decorator_staticmethod(self): mcp = FastMCP() @@ -549,8 +525,7 @@ class TestTemplateDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://test/data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Static Data for test" + assert result[0].text == "Static Data for test" # type: ignore[attr-defined] async def test_template_decorator_async_function(self): mcp = FastMCP() @@ -561,8 +536,7 @@ class TestTemplateDecorator: async with Client(mcp) as client: result = await client.read_resource("resource://test/data") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Async Data for test" + assert result[0].text == "Async Data for test" # type: ignore[attr-defined] async def test_template_decorator_with_tags(self): """Test that the template decorator properly sets tags.""" @@ -603,8 +577,7 @@ class TestPromptDecorator: assert prompt.name == "fn" # Don't compare functions directly since validate_call wraps them content = await prompt.render() - assert isinstance(content[0].content, TextContent) - assert content[0].content.text == "Hello, world!" + assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_incorrect_usage(self): mcp = FastMCP() @@ -629,8 +602,7 @@ class TestPromptDecorator: prompt = prompts_dict["custom_name"] assert prompt.name == "custom_name" content = await prompt.render() - assert isinstance(content[0].content, TextContent) - assert content[0].content.text == "Hello, world!" + assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_with_description(self): mcp = FastMCP() @@ -644,8 +616,7 @@ class TestPromptDecorator: prompt = prompts_dict["fn"] assert prompt.description == "A custom description" content = await prompt.render() - assert isinstance(content[0].content, TextContent) - assert content[0].content.text == "Hello, world!" + assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_with_parameters(self): mcp = FastMCP() @@ -668,16 +639,14 @@ class TestPromptDecorator: result = await client.get_prompt("test_prompt", {"name": "World"}) assert len(result.messages) == 1 message = result.messages[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Hello, World!" + assert message.content.text == "Hello, World!" # type: ignore[attr-defined] result = await client.get_prompt( "test_prompt", {"name": "World", "greeting": "Hi"} ) assert len(result.messages) == 1 message = result.messages[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Hi, World!" + assert message.content.text == "Hi, World!" # type: ignore[attr-defined] async def test_prompt_decorator_instance_method(self): mcp = FastMCP() @@ -696,8 +665,7 @@ class TestPromptDecorator: result = await client.get_prompt("test_prompt") assert len(result.messages) == 1 message = result.messages[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "My prefix: Hello, world!" + assert message.content.text == "My prefix: Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_classmethod(self): mcp = FastMCP() @@ -715,8 +683,7 @@ class TestPromptDecorator: result = await client.get_prompt("test_prompt") assert len(result.messages) == 1 message = result.messages[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Class prefix: Hello, world!" + assert message.content.text == "Class prefix: Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_staticmethod(self): mcp = FastMCP() @@ -731,8 +698,7 @@ class TestPromptDecorator: result = await client.get_prompt("test_prompt") assert len(result.messages) == 1 message = result.messages[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Static Hello, world!" + assert message.content.text == "Static Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_async_function(self): mcp = FastMCP() @@ -745,8 +711,7 @@ class TestPromptDecorator: result = await client.get_prompt("test_prompt") assert len(result.messages) == 1 message = result.messages[0] - assert isinstance(message.content, TextContent) - assert message.content.text == "Async Hello, world!" + assert message.content.text == "Async Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_with_tags(self): """Test that the prompt decorator properly sets tags.""" @@ -943,20 +908,17 @@ class TestResourcePrefixMounting: async with Client(main_server) as client: # Regular resource result = await client.read_resource("resource://prefix/test-resource") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Resource content" + assert result[0].text == "Resource content" # type: ignore[attr-defined] # Absolute path resource result = await client.read_resource("resource://prefix//absolute/path") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Absolute resource content" + assert result[0].text == "Absolute resource content" # type: ignore[attr-defined] # Template resource result = await client.read_resource( "resource://prefix/param-value/template" ) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Template resource with param-value" + assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined] @pytest.mark.parametrize( "uri,prefix,expected_match,expected_strip", @@ -1032,15 +994,12 @@ class TestResourcePrefixMounting: # Verify we can access the resources async with Client(target_server) as client: result = await client.read_resource("resource://imported/test-resource") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Resource content" + assert result[0].text == "Resource content" # type: ignore[attr-defined] result = await client.read_resource("resource://imported//absolute/path") - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Absolute resource content" + assert result[0].text == "Absolute resource content" # type: ignore[attr-defined] result = await client.read_resource( "resource://imported/param-value/template" ) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Template resource with param-value" + assert result[0].text == "Template resource with param-value" # type: ignore[attr-defined] diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index d7c7577c5..6d7c9ed4c 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -10,7 +10,6 @@ import pydantic_core import pytest from mcp import McpError from mcp.types import ( - BlobResourceContents, ImageContent, TextContent, TextResourceContents, @@ -77,14 +76,12 @@ class TestTools: async def test_call_tool(self, tool_server: FastMCP): async with Client(tool_server) as client: result = await client.call_tool("add", {"x": 1, "y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async def test_call_tool_as_client(self, tool_server: FastMCP): async with Client(tool_server) as client: result = await client.call_tool("add", {"x": 1, "y": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async def test_call_tool_error(self, tool_server: FastMCP): async with Client(tool_server) as client: @@ -113,8 +110,7 @@ class TestTools: async def test_tool_returns_list(self, tool_server: FastMCP): async with Client(tool_server) as client: result = await client.call_tool("list_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == '[\n "x",\n 2\n]' + assert result[0].text == '[\n "x",\n 2\n]' # type: ignore[attr-defined] class TestToolReturnTypes: @@ -127,8 +123,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("string_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == "Hello, world!" + assert result[0].text == "Hello, world!" # type: ignore[attr-defined] async def test_bytes(self, tmp_path: Path): mcp = FastMCP() @@ -139,8 +134,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("bytes_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == '"Hello, world!"' + assert result[0].text == '"Hello, world!"' # type: ignore[attr-defined] async def test_uuid(self): mcp = FastMCP() @@ -153,8 +147,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("uuid_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == pydantic_core.to_json(test_uuid).decode() + assert result[0].text == pydantic_core.to_json(test_uuid).decode() # type: ignore[attr-defined] async def test_path(self): mcp = FastMCP() @@ -167,8 +160,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("path_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == pydantic_core.to_json(test_path).decode() + assert result[0].text == pydantic_core.to_json(test_path).decode() # type: ignore[attr-defined] async def test_datetime(self): mcp = FastMCP() @@ -181,8 +173,7 @@ class TestToolReturnTypes: async with Client(mcp) as client: result = await client.call_tool("datetime_tool", {}) - assert isinstance(result[0], TextContent) - assert result[0].text == pydantic_core.to_json(dt).decode() + assert result[0].text == pydantic_core.to_json(dt).decode() # type: ignore[attr-defined] async def test_image(self, tmp_path: Path): mcp = FastMCP() @@ -337,8 +328,7 @@ class TestToolParameters: async with Client(mcp) as client: # String with integer value should be coerced to int result = await client.call_tool("add_one", {"x": "42"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "43" + assert result[0].text == "43" # type: ignore[attr-defined] async def test_tool_bool_coercion(self): """Test string-to-bool type coercion.""" @@ -351,12 +341,10 @@ class TestToolParameters: async with Client(mcp) as client: # String with boolean value should be coerced to bool result = await client.call_tool("toggle", {"flag": "true"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "false" + assert result[0].text == "false" # type: ignore[attr-defined] result = await client.call_tool("toggle", {"flag": "false"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "true" + assert result[0].text == "true" # type: ignore[attr-defined] async def test_annotated_field_validation(self): mcp = FastMCP() @@ -411,8 +399,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("analyze", {"x": "a"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "a" + assert result[0].text == "a" # type: ignore[attr-defined] async def test_enum_type_validation_error(self): mcp = FastMCP() @@ -444,8 +431,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("analyze", {"x": "red"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "red" + assert result[0].text == "red" # type: ignore[attr-defined] async def test_union_type_validation(self): mcp = FastMCP() @@ -456,12 +442,10 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("analyze", {"x": 1}) - assert isinstance(result[0], TextContent) - assert result[0].text == "1" + assert result[0].text == "1" # type: ignore[attr-defined] result = await client.call_tool("analyze", {"x": 1.0}) - assert isinstance(result[0], TextContent) - assert result[0].text == "1.0" + assert result[0].text == "1.0" # type: ignore[attr-defined] with pytest.raises(ToolError, match="Error calling tool 'analyze'"): await client.call_tool("analyze", {"x": "not a number"}) @@ -479,8 +463,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_path", {"path": str(test_path)}) - assert isinstance(result[0], TextContent) - assert result[0].text == str(test_path) + assert result[0].text == str(test_path) # type: ignore[attr-defined] async def test_path_type_error(self): mcp = FastMCP() @@ -505,8 +488,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_uuid", {"x": test_uuid}) - assert isinstance(result[0], TextContent) - assert result[0].text == str(test_uuid) + assert result[0].text == str(test_uuid) # type: ignore[attr-defined] async def test_uuid_type_error(self): mcp = FastMCP() @@ -530,8 +512,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_datetime", {"x": dt}) - assert isinstance(result[0], TextContent) - assert result[0].text == dt.isoformat() + assert result[0].text == dt.isoformat() # type: ignore[attr-defined] async def test_datetime_type_parse_string(self): mcp = FastMCP() @@ -544,8 +525,7 @@ class TestToolParameters: result = await client.call_tool( "send_datetime", {"x": "2021-01-01T00:00:00"} ) - assert isinstance(result[0], TextContent) - assert result[0].text == "2021-01-01T00:00:00" + assert result[0].text == "2021-01-01T00:00:00" # type: ignore[attr-defined] async def test_datetime_type_error(self): mcp = FastMCP() @@ -567,8 +547,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_date", {"x": datetime.date.today()}) - assert isinstance(result[0], TextContent) - assert result[0].text == datetime.date.today().isoformat() + assert result[0].text == datetime.date.today().isoformat() # type: ignore[attr-defined] async def test_date_type_parse_string(self): mcp = FastMCP() @@ -579,8 +558,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_date", {"x": "2021-01-01"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "2021-01-01" + assert result[0].text == "2021-01-01" # type: ignore[attr-defined] async def test_timedelta_type(self): mcp = FastMCP() @@ -593,8 +571,7 @@ class TestToolParameters: result = await client.call_tool( "send_timedelta", {"x": datetime.timedelta(days=1)} ) - assert isinstance(result[0], TextContent) - assert result[0].text == "1 day, 0:00:00" + assert result[0].text == "1 day, 0:00:00" # type: ignore[attr-defined] async def test_timedelta_type_parse_int(self): mcp = FastMCP() @@ -605,8 +582,7 @@ class TestToolParameters: async with Client(mcp) as client: result = await client.call_tool("send_timedelta", {"x": 1000}) - assert isinstance(result[0], TextContent) - assert result[0].text == "0:16:40" + assert result[0].text == "0:16:40" # type: ignore[attr-defined] class TestToolContextInjection: @@ -639,7 +615,7 @@ class TestToolContextInjection: result = await client.call_tool("tool_with_context", {"x": 42}) assert len(result) == 1 content = result[0] - assert isinstance(content, TextContent) + assert content.text == "2" # type: ignore[attr-defined] async def test_async_context(self): """Test that context works in async functions.""" @@ -654,8 +630,7 @@ class TestToolContextInjection: result = await client.call_tool("async_tool", {"x": 42}) assert len(result) == 1 content = result[0] - assert isinstance(content, TextContent) - assert content.text == "Async request 2: 42" + assert content.text == "Async request 2: 42" # type: ignore[attr-defined] async def test_optional_context(self): """Test that context is optional.""" @@ -669,8 +644,7 @@ class TestToolContextInjection: result = await client.call_tool("no_context", {"x": 21}) assert len(result) == 1 content = result[0] - assert isinstance(content, TextContent) - assert content.text == "42" + assert content.text == "42" # type: ignore[attr-defined] async def test_context_resource_access(self): """Test that context can access resources.""" @@ -692,8 +666,7 @@ class TestToolContextInjection: result = await client.call_tool("tool_with_resource", {}) assert len(result) == 1 content = result[0] - assert isinstance(content, TextContent) - assert "Read resource: resource data" in content.text + assert "Read resource: resource data" in content.text # type: ignore[attr-defined] async def test_tool_decorator_with_tags(self): """Test that the tool decorator properly sets tags.""" @@ -721,8 +694,7 @@ class TestToolContextInjection: async with Client(mcp) as client: result = await client.call_tool("MyTool", {"x": 2}) - assert isinstance(result[0], TextContent) - assert result[0].text == "4" + assert result[0].text == "4" # type: ignore[attr-defined] class TestResource: @@ -739,8 +711,7 @@ class TestResource: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Hello, world!" + assert result[0].text == "Hello, world!" # type: ignore[attr-defined] async def test_binary_resource(self): mcp = FastMCP() @@ -758,8 +729,7 @@ class TestResource: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://binary")) - assert isinstance(result[0], BlobResourceContents) - assert result[0].blob == base64.b64encode(b"Binary data").decode() + assert result[0].blob == base64.b64encode(b"Binary data").decode() # type: ignore[attr-defined] async def test_file_resource_text(self, tmp_path: Path): mcp = FastMCP() @@ -775,8 +745,7 @@ class TestResource: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("file://test.txt")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Hello from file!" + assert result[0].text == "Hello from file!" # type: ignore[attr-defined] async def test_file_resource_binary(self, tmp_path: Path): mcp = FastMCP() @@ -795,8 +764,7 @@ class TestResource: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("file://test.bin")) - assert isinstance(result[0], BlobResourceContents) - assert result[0].blob == base64.b64encode(b"Binary file data").decode() + assert result[0].blob == base64.b64encode(b"Binary file data").decode() # type: ignore[attr-defined] class TestResourceContext: @@ -810,8 +778,7 @@ class TestResourceContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "2" + assert result[0].text == "2" # type: ignore[attr-defined] class TestResourceTemplates: @@ -860,8 +827,7 @@ class TestResourceTemplates: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test/data")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Data for test" + assert result[0].text == "Data for test" # type: ignore[attr-defined] async def test_resource_mismatched_params(self): """Test that mismatched parameters raise an error""" @@ -888,8 +854,7 @@ class TestResourceTemplates: result = await client.read_resource( AnyUrl("resource://cursor/fastmcp/data") ) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Data for cursor/fastmcp" + assert result[0].text == "Data for cursor/fastmcp" # type: ignore[attr-defined] async def test_resource_multiple_mismatched_params(self): """Test that mismatched parameters raise an error""" @@ -913,8 +878,7 @@ class TestResourceTemplates: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://static")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Static data" + assert result[0].text == "Static data" # type: ignore[attr-defined] async def test_template_with_varkwargs(self): """Test that a template can have **kwargs.""" @@ -926,8 +890,7 @@ class TestResourceTemplates: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("test://1/2/3")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "6" + assert result[0].text == "6" # type: ignore[attr-defined] async def test_template_with_default_params(self): """Test that a template can have default parameters.""" @@ -946,13 +909,11 @@ class TestResourceTemplates: # Call the template and verify it uses the default value async with Client(mcp) as client: result = await client.read_resource(AnyUrl("math://add/5")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "15" # 5 + default 10 + assert result[0].text == "15" # type: ignore[attr-defined] # Can also call with explicit params result2 = await client.read_resource(AnyUrl("math://add/7")) - assert isinstance(result2[0], TextResourceContents) - assert result2[0].text == "17" # 7 + default 10 + assert result2[0].text == "17" # type: ignore[attr-defined] async def test_template_to_resource_conversion(self): """Test that a template can be converted to a resource.""" @@ -971,8 +932,7 @@ class TestResourceTemplates: # When accessed, should create a concrete resource async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test/data")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Data for test" + assert result[0].text == "Data for test" # type: ignore[attr-defined] async def test_stacked_resource_template_decorators(self): """Test that resource template decorators can be stacked.""" @@ -1011,15 +971,15 @@ class TestResourceTemplates: email_result = await client.read_resource( AnyUrl("users://email/user@example.com") ) - assert isinstance(email_result[0], TextResourceContents) - email_data = json.loads(email_result[0].text) + assert email_result[0].text # type: ignore[attr-defined] + email_data = json.loads(email_result[0].text) # type: ignore[attr-defined] assert email_data["lookup"] == "email" assert email_data["email"] == "user@example.com" # Test lookup by name name_result = await client.read_resource(AnyUrl("users://name/John")) - assert isinstance(name_result[0], TextResourceContents) - name_data = json.loads(name_result[0].text) + assert name_result[0].text # type: ignore[attr-defined] + name_ assert name_data["lookup"] == "name" assert name_data["name"] == "John" assert name_data["email"] == "dummy@example.com" @@ -1044,8 +1004,7 @@ class TestResourceTemplates: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test/data")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Template resource: test/data" + assert result[0].text == "Template resource: test/data" # type: ignore[attr-defined] async def test_templates_match_in_order_of_definition(self): """ @@ -1065,12 +1024,10 @@ class TestResourceTemplates: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://a/b/c")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Template resource 1: a/b/c" + assert result[0].text == "Template resource 1: a/b/c" # type: ignore[attr-defined] result = await client.read_resource(AnyUrl("resource://a/b")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Template resource 1: a/b" + assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined] async def test_templates_shadow_each_other_reorder(self): """ @@ -1089,12 +1046,10 @@ class TestResourceTemplates: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://a/b/c")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Template resource 2: a/b/c" + assert result[0].text == "Template resource 2: a/b/c" # type: ignore[attr-defined] result = await client.read_resource(AnyUrl("resource://a/b")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text == "Template resource 1: a/b" + assert result[0].text == "Template resource 1: a/b" # type: ignore[attr-defined] class TestResourceTemplateContext: @@ -1108,8 +1063,7 @@ class TestResourceTemplateContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text.startswith("Resource template: test 2") + assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined] async def test_resource_template_context_with_callable_object(self): mcp = FastMCP() @@ -1122,8 +1076,7 @@ class TestResourceTemplateContext: async with Client(mcp) as client: result = await client.read_resource(AnyUrl("resource://test")) - assert isinstance(result[0], TextResourceContents) - assert result[0].text.startswith("Resource template: test 2") + assert result[0].text.startswith("Resource template: test 2") # type: ignore[attr-defined] class TestPrompts: @@ -1143,8 +1096,7 @@ class TestPrompts: assert prompt.name == "fn" # Don't compare functions directly since validate_call wraps them content = await prompt.render() - assert isinstance(content[0].content, TextContent) - assert content[0].content.text == "Hello, world!" + assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_with_name(self): """Test prompt decorator with custom name.""" @@ -1159,8 +1111,7 @@ class TestPrompts: prompt = prompts_dict["custom_name"] assert prompt.name == "custom_name" content = await prompt.render() - assert isinstance(content[0].content, TextContent) - assert content[0].content.text == "Hello, world!" + assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined] async def test_prompt_decorator_with_description(self): """Test prompt decorator with custom description.""" @@ -1175,8 +1126,7 @@ class TestPrompts: prompt = prompts_dict["fn"] assert prompt.description == "A custom description" content = await prompt.render() - assert isinstance(content[0].content, TextContent) - assert content[0].content.text == "Hello, world!" + assert content[0].content.text == "Hello, world!" # type: ignore[attr-defined] def test_prompt_decorator_error(self): """Test error when decorator is used incorrectly.""" @@ -1224,8 +1174,7 @@ class TestPrompts: message = result.messages[0] assert message.role == "user" content = message.content - assert isinstance(content, TextContent) - assert content.text == "Hello, World!" + assert content.text == "Hello, World!" # type: ignore[attr-defined] async def test_get_prompt_with_resource(self): """Test getting a prompt that returns resource content.""" @@ -1249,10 +1198,10 @@ class TestPrompts: result = await client.get_prompt("fn") assert result.messages[0].role == "user" content = result.messages[0].content - assert isinstance(content, EmbeddedResource) + assert isinstance(content, EmbeddedResource) # type: ignore[attr-defined] resource = content.resource - assert isinstance(resource, TextResourceContents) - assert resource.text == "File contents" + assert isinstance(resource, TextResourceContents) # type: ignore[attr-defined] + assert resource.text == "File contents" # type: ignore[attr-defined] assert resource.mimeType == "text/plain" async def test_get_unknown_prompt(self): @@ -1342,5 +1291,4 @@ class TestPromptContext: assert len(result.messages) == 1 message = result.messages[0] assert message.role == "user" - assert isinstance(message.content, TextContent) - assert message.content.text == "Hello, World! 2" + assert message.content.text == "Hello, World! 2" # type: ignore[attr-defined] diff --git a/tests/server/test_tool_annotations.py b/tests/server/test_tool_annotations.py index eee54de76..dfe2ef744 100644 --- a/tests/server/test_tool_annotations.py +++ b/tests/server/test_tool_annotations.py @@ -1,6 +1,6 @@ from typing import Any -from mcp.types import TextContent, ToolAnnotations +from mcp.types import ToolAnnotations from fastmcp import Client, FastMCP @@ -212,8 +212,7 @@ async def test_tool_functionality_with_annotations(): "create_item", {"name": "test_item", "value": 42} ) assert len(result) == 1 - assert isinstance(result[0], TextContent) # The result should contain the expected JSON - assert '"name": "test_item"' in result[0].text - assert '"value": 42' in result[0].text + assert '"name": "test_item"' in result[0].text # type: ignore[attr-defined] + assert '"value": 42' in result[0].text # type: ignore[attr-defined] diff --git a/tests/test_examples.py b/tests/test_examples.py index fcee6c521..0fa1da3a4 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -1,10 +1,5 @@ """Tests for example servers""" -from mcp.types import ( - PromptMessage, - TextContent, - TextResourceContents, -) from pydantic import AnyUrl from fastmcp import Client @@ -17,8 +12,7 @@ async def test_simple_echo(): async with Client(mcp) as client: result = await client.call_tool("echo", {"text": "hello"}) assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "hello" + assert result[0].text == "hello" # type: ignore[attr-defined] async def test_complex_inputs(): @@ -31,8 +25,7 @@ async def test_complex_inputs(): "name_shrimp", {"tank": tank, "extra_names": ["charlie"]} ) assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' + assert result[0].text == '[\n "bob",\n "alice",\n "charlie"\n]' # type: ignore[attr-defined] async def test_desktop(monkeypatch): @@ -43,15 +36,12 @@ async def test_desktop(monkeypatch): # Test the add function result = await client.call_tool("add", {"a": 1, "b": 2}) assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "3" + assert result[0].text == "3" # type: ignore[attr-defined] async with Client(mcp) as client: result = await client.read_resource(AnyUrl("greeting://rooter12")) assert len(result) == 1 - assert isinstance(result[0], TextResourceContents) - assert isinstance(result[0].text, str) - assert result[0].text == "Hello, rooter12!" + assert result[0].text == "Hello, rooter12!" # type: ignore[attr-defined] async def test_echo(): @@ -61,27 +51,19 @@ async def test_echo(): async with Client(mcp) as client: result = await client.call_tool("echo_tool", {"text": "hello"}) assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "hello" + assert result[0].text == "hello" # type: ignore[attr-defined] async with Client(mcp) as client: result = await client.read_resource(AnyUrl("echo://static")) assert len(result) == 1 - assert isinstance(result[0], TextResourceContents) - assert isinstance(result[0].text, str) - assert result[0].text == "Echo!" + assert result[0].text == "Echo!" # type: ignore[attr-defined] async with Client(mcp) as client: result = await client.read_resource(AnyUrl("echo://server42")) assert len(result) == 1 - assert isinstance(result[0], TextResourceContents) - assert isinstance(result[0].text, str) - assert result[0].text == "Echo: server42" + assert result[0].text == "Echo: server42" # type: ignore[attr-defined] async with Client(mcp) as client: result = await client.get_prompt("echo", {"text": "hello"}) assert len(result.messages) == 1 - assert isinstance(result.messages[0], PromptMessage) - assert isinstance(result.messages[0].content, TextContent) - assert isinstance(result.messages[0].content.text, str) - assert result.messages[0].content.text == "hello" + assert result.messages[0].content.text == "hello" # type: ignore[attr-defined] diff --git a/tests/tools/test_tool.py b/tests/tools/test_tool.py index 141035534..bcb9a671a 100644 --- a/tests/tools/test_tool.py +++ b/tests/tools/test_tool.py @@ -1,5 +1,5 @@ import pytest -from mcp.types import ImageContent, TextContent +from mcp.types import ImageContent from pydantic import BaseModel from fastmcp import FastMCP, Image @@ -209,9 +209,7 @@ class TestLegacyToolJsonParsing: # Run the tool which will do JSON parsing result = await tool.run(json_args) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "1-a,b,c" + assert result[0].text == "1-a,b,c" # type: ignore[attr-dict] async def test_str_vs_list_str(self): """Test handling of string vs list[str] type annotations.""" @@ -223,23 +221,17 @@ class TestLegacyToolJsonParsing: # Test regular string input (should remain a string) result = await tool.run({"str_or_list": "hello"}) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "hello" + assert result[0].text == "hello" # type: ignore[attr-dict] # Test JSON string input (should be parsed as a string) result = await tool.run({"str_or_list": '"hello"'}) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "hello" + assert result[0].text == "hello" # type: ignore[attr-dict] # Test JSON list input (should be parsed as a list) result = await tool.run({"str_or_list": '["hello", "world"]'}) - assert len(result) == 1 - assert isinstance(result[0], TextContent) # The exact formatting might vary, so we just check that it contains the key elements - text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "") + text_without_whitespace = result[0].text.replace(" ", "").replace("\n", "") # type: ignore[attr-dict] assert "hello" in text_without_whitespace assert "world" in text_without_whitespace assert "[" in text_without_whitespace @@ -256,9 +248,7 @@ class TestLegacyToolJsonParsing: # Invalid JSON should remain a string invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}" result = await tool.run({"string": invalid_json}) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == invalid_json + assert result[0].text == invalid_json # type: ignore[attr-dict] async def test_keep_str_union_as_str(self): """Test that string arguments are kept as strings when parsing would create an invalid value""" @@ -273,9 +263,7 @@ class TestLegacyToolJsonParsing: # Invalid JSON for the union type should remain a string invalid_json = "{'nice to meet you': 'hello', 'goodbye': 5}" result = await tool.run({"string": invalid_json}) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == invalid_json + assert result[0].text == invalid_json # type: ignore[attr-dict] async def test_complex_type_validation(self): """Test that parsed JSON is validated against complex types""" @@ -292,11 +280,9 @@ class TestLegacyToolJsonParsing: # Valid JSON for the model valid_json = '{"x": 1, "y": {"1": "hello"}}' result = await tool.run({"data": valid_json}) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert '"x": 1' in result[0].text - assert '"y": {' in result[0].text - assert '"1": "hello"' in result[0].text + assert '"x": 1' in result[0].text # type: ignore[attr-dict] + assert '"y": {' in result[0].text # type: ignore[attr-dict] + assert '"1": "hello"' in result[0].text # type: ignore[attr-dict] # Invalid JSON for the model (y has string keys, not int keys) # Should throw a validation error @@ -317,8 +303,7 @@ class TestLegacyToolJsonParsing: result = await client.call_tool( "process_list", {"items": "[1, 2, 3, 4, 5]"} ) - assert isinstance(result[0], TextContent) - assert result[0].text == "15" + assert result[0].text == "15" # type: ignore[attr-dict] async def test_tool_list_coercion_error(self): """Test that a list coercion error is raised if the input is not a valid list.""" @@ -348,8 +333,7 @@ class TestLegacyToolJsonParsing: result = await client.call_tool( "process_dict", {"data": '{"a": 1, "b": "2", "c": 3}'} ) - assert isinstance(result[0], TextContent) - assert result[0].text == "6" + assert result[0].text == "6" # type: ignore[attr-dict] async def test_tool_set_coercion(self): """Test JSON string to set type coercion.""" @@ -362,8 +346,7 @@ class TestLegacyToolJsonParsing: async with Client(mcp) as client: result = await client.call_tool("process_set", {"items": "[1, 2, 3, 4, 5]"}) - assert isinstance(result[0], TextContent) - assert result[0].text == "15" + assert result[0].text == "15" # type: ignore[attr-dict] async def test_tool_tuple_coercion(self): """Test JSON string to tuple type coercion.""" @@ -376,5 +359,4 @@ class TestLegacyToolJsonParsing: async with Client(mcp) as client: result = await client.call_tool("process_tuple", {"items": '["1", "two"]'}) - assert isinstance(result[0], TextContent) - assert result[0].text == "4" + assert result[0].text == "4" # type: ignore[attr-dict] diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 5cc3aa33e..75ccf0349 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -5,7 +5,7 @@ from typing import Annotated, Any import pydantic_core import pytest -from mcp.types import ImageContent, TextContent +from mcp.types import ImageContent from pydantic import BaseModel from fastmcp import Context, FastMCP, Image @@ -318,13 +318,8 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(add) result = await manager.call_tool("add", {"a": 1, "b": 2}) - assert isinstance(result, list) - assert len(result) == 1 - from mcp.types import TextContent - assert isinstance(result[0], TextContent) - assert result[0].text == "3" - assert json.loads(result[0].text) == 3 + assert result[0].text == "3" # type: ignore[attr-defined] async def test_call_async_tool(self): async def double(n: int) -> int: @@ -334,12 +329,7 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(double) result = await manager.call_tool("double", {"n": 5}) - assert isinstance(result, list) - assert len(result) == 1 - - assert isinstance(result[0], TextContent) - assert result[0].text == "10" - assert json.loads(result[0].text) == 10 + assert result[0].text == "10" # type: ignore[attr-defined] async def test_call_tool_callable_object(self): class Adder: @@ -352,11 +342,7 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(Adder()) result = await manager.call_tool("Adder", {"x": 1, "y": 2}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "3" - assert json.loads(result[0].text) == 3 + assert result[0].text == "3" # type: ignore[attr-defined] async def test_call_tool_callable_object_async(self): class Adder: @@ -369,11 +355,7 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(Adder()) result = await manager.call_tool("Adder", {"x": 1, "y": 2}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "3" - assert json.loads(result[0].text) == 3 + assert result[0].text == "3" # type: ignore[attr-defined] async def test_call_tool_with_default_args(self): def add(a: int, b: int = 1) -> int: @@ -383,12 +365,8 @@ class TestCallTools: manager = ToolManager() manager.add_tool_from_fn(add) result = await manager.call_tool("add", {"a": 1}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "2" - assert json.loads(result[0].text) == 2 + assert result[0].text == "2" # type: ignore[attr-defined] async def test_call_tool_with_missing_args(self): def add(a: int, b: int) -> int: @@ -413,11 +391,7 @@ class TestCallTools: manager.add_tool_from_fn(sum_vals) result = await manager.call_tool("sum_vals", {"vals": [1, 2, 3]}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "6" - assert json.loads(result[0].text) == 6 + assert result[0].text == "6" # type: ignore[attr-defined] async def test_call_tool_with_list_int_input_legacy_behavior(self): """Legacy behavior -- parse a stringified JSON object""" @@ -431,11 +405,7 @@ class TestCallTools: with temporary_settings(tool_attempt_parse_json_args=True): result = await manager.call_tool("sum_vals", {"vals": "[1, 2, 3]"}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "6" - assert json.loads(result[0].text) == 6 + assert result[0].text == "6" # type: ignore[attr-defined] async def test_call_tool_with_list_str_or_str_input(self): def concat_strs(vals: list[str] | str) -> str: @@ -446,16 +416,10 @@ class TestCallTools: # Try both with plain python object and with JSON list result = await manager.call_tool("concat_strs", {"vals": ["a", "b", "c"]}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "abc" + assert result[0].text == "abc" # type: ignore[attr-defined] result = await manager.call_tool("concat_strs", {"vals": "a"}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "a" + assert result[0].text == "a" # type: ignore[attr-defined] async def test_call_tool_with_list_str_or_str_input_legacy_behavior(self): """Legacy behavior -- parse a stringified JSON object""" @@ -468,16 +432,10 @@ class TestCallTools: with temporary_settings(tool_attempt_parse_json_args=True): result = await manager.call_tool("concat_strs", {"vals": '["a", "b", "c"]'}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "abc" + assert result[0].text == "abc" # type: ignore[attr-defined] result = await manager.call_tool("concat_strs", {"vals": '"a"'}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "a" + assert result[0].text == "a" # type: ignore[attr-defined] async def test_call_tool_with_complex_model(self): class MyShrimpTank(BaseModel): @@ -507,10 +465,7 @@ class TestCallTools: }, ) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == '[\n "rex",\n "gertrude"\n]' + assert result[0].text == '[\n "rex",\n "gertrude"\n]' # type: ignore[attr-defined] async def test_call_tool_with_custom_serializer(self): """Test that a custom serializer provided to FastMCP is used by tools.""" @@ -530,10 +485,7 @@ class TestCallTools: manager.add_tool_from_fn(get_data) result = await manager.call_tool("get_data", {}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' + assert result[0].text == 'CUSTOM:{"key": "value", "number": 123}' # type: ignore[attr-defined] async def test_call_tool_with_list_result_custom_serializer(self): """Test that a custom serializer provided to FastMCP is used by tools that return lists.""" @@ -555,12 +507,9 @@ class TestCallTools: manager.add_tool_from_fn(get_data) result = await manager.call_tool("get_data", {}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) assert ( - result[0].text - == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' + result[0].text # type: ignore[attr-defined] + == 'CUSTOM:[{"key": "value", "number": 123}, {"key": "value2", "number": 456}]' # type: ignore[attr-defined] ) async def test_custom_serializer_fallback_on_error(self): @@ -580,10 +529,7 @@ class TestCallTools: manager.add_tool_from_fn(get_data) result = await manager.call_tool("get_data", {}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == pydantic_core.to_json(uuid_result).decode() + assert result[0].text == pydantic_core.to_json(uuid_result).decode() # type: ignore[attr-defined] class TestToolSchema: @@ -648,10 +594,7 @@ class TestContextHandling: with context: result = await manager.call_tool("tool_with_context", {"x": 42}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "42" + assert result[0].text == "42" # type: ignore[attr-defined] async def test_context_injection_async(self): """Test that context is properly injected in async tools.""" @@ -668,14 +611,10 @@ class TestContextHandling: with context: result = await manager.call_tool("async_tool", {"x": 42}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "42" + assert result[0].text == "42" # type: ignore[attr-defined] async def test_context_optional(self): """Test that context is optional when calling tools.""" - from mcp.types import TextContent def tool_with_context(x: int, ctx: Context | None) -> int: return x @@ -689,10 +628,7 @@ class TestContextHandling: with context: result = await manager.call_tool("tool_with_context", {"x": 42}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "42" + assert result[0].text == "42" # type: ignore[attr-defined] def test_parameterized_context_parameter_detection(self): """Test that context parameters are properly detected in @@ -782,7 +718,6 @@ class TestCustomToolNames: async def test_call_tool_with_custom_name(self): """Test calling a tool added with a custom name.""" - from mcp.types import TextContent def multiply(a: int, b: int) -> int: """Multiply two numbers.""" @@ -793,11 +728,7 @@ class TestCustomToolNames: # Tool should be callable by its custom name result = await manager.call_tool("custom_multiply", {"a": 5, "b": 3}) - assert isinstance(result, list) - assert len(result) == 1 - assert isinstance(result[0], TextContent) - assert result[0].text == "15" - assert json.loads(result[0].text) == 15 + assert result[0].text == "15" # type: ignore[attr-defined] # Original name should not be registered with pytest.raises(NotFoundError, match="Unknown tool: multiply"): diff --git a/tests/utilities/test_mcp_config.py b/tests/utilities/test_mcp_config.py index b7737da1d..bd0c84bee 100644 --- a/tests/utilities/test_mcp_config.py +++ b/tests/utilities/test_mcp_config.py @@ -1,8 +1,6 @@ import inspect from pathlib import Path -from mcp.types import TextContent - from fastmcp.client.client import Client from fastmcp.client.transports import ( SSETransport, @@ -136,7 +134,5 @@ async def test_multi_client(tmp_path: Path): result_1 = await client.call_tool("test_1_add", {"a": 1, "b": 2}) result_2 = await client.call_tool("test_2_add", {"a": 1, "b": 2}) - assert isinstance(result_1[0], TextContent) - assert result_1[0].text == "3" - assert isinstance(result_2[0], TextContent) - assert result_2[0].text == "3" + assert result_1[0].text == "3" # type: ignore[attr-dict] + assert result_2[0].text == "3" # type: ignore[attr-dict] From 39c8f398635be711ad9f3eba8c3018b66516f217 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 20:46:12 -0400 Subject: [PATCH 14/34] Fix remaining typing issues --- tests/server/test_import_server.py | 2 +- tests/server/test_server_interactions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index ed03567be..2e004bbfe 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -321,7 +321,7 @@ async def test_import_with_proxy_prompts(): await main_app.import_server("api", proxy_app) result = await main_app._mcp_get_prompt("api_greeting", {"name": "World"}) - assert result.messages[0].content.text == "Hello, World from API!" + assert result.messages[0].content.text == "Hello, World from API!" # type: ignore[attr-defined] assert result.description == "Example greeting prompt." diff --git a/tests/server/test_server_interactions.py b/tests/server/test_server_interactions.py index 6d7c9ed4c..3264c0601 100644 --- a/tests/server/test_server_interactions.py +++ b/tests/server/test_server_interactions.py @@ -979,7 +979,7 @@ class TestResourceTemplates: # Test lookup by name name_result = await client.read_resource(AnyUrl("users://name/John")) assert name_result[0].text # type: ignore[attr-defined] - name_ + name_data = json.loads(name_result[0].text) # type: ignore[attr-defined] assert name_data["lookup"] == "name" assert name_data["name"] == "John" assert name_data["email"] == "dummy@example.com" From 8a0f9aab5acd90e7e2b20d9b89095f437f3e71d2 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 22:42:00 -0400 Subject: [PATCH 15/34] Support providing tools at init --- src/fastmcp/server/server.py | 8 ++++++++ tests/server/test_server.py | 19 +++++++++++++++++++ uv.lock | 23 +++++++++++------------ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 19c2ea574..5af2b596d 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -127,6 +127,7 @@ class FastMCP(Generic[LifespanResultT]): on_duplicate_prompts: DuplicateBehavior | None = None, resource_prefix_format: Literal["protocol", "path"] | None = None, mask_error_details: bool | None = None, + tools: list[Tool | Callable[..., Any]] | None = None, **settings: Any, ): if settings: @@ -187,6 +188,13 @@ class FastMCP(Generic[LifespanResultT]): self.auth = auth + if tools: + for tool in tools: + if isinstance(tool, Tool): + self._tool_manager.add_tool(tool) + else: + self.add_tool(tool) + # Set up MCP protocol handlers self._setup_handlers() diff --git a/tests/server/test_server.py b/tests/server/test_server.py index 56ae434ba..a5416229d 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -12,6 +12,7 @@ from fastmcp.server.server import ( has_resource_prefix, remove_resource_prefix, ) +from fastmcp.tools.tool import Tool class TestCreateServer: @@ -93,6 +94,24 @@ class TestTools: with pytest.raises(NotFoundError, match="Unknown tool: adder"): await mcp._mcp_call_tool("adder", {"a": 1, "b": 2}) + async def test_add_tool_at_init(self): + def f(x: int) -> int: + return x + 1 + + def g(x: int) -> int: + """add two to a number""" + return x + 2 + + g_tool = Tool.from_function(g, name="g-tool") + + mcp = FastMCP(tools=[f, g_tool]) + + tools = await mcp.get_tools() + assert len(tools) == 2 + assert tools["f"].name == "f" + assert tools["g-tool"].name == "g-tool" + assert tools["g-tool"].description == "add two to a number" + class TestToolDecorator: async def test_no_tools_before_decorator(self): diff --git a/uv.lock b/uv.lock index 93f9a4262..ae92d334e 100644 --- a/uv.lock +++ b/uv.lock @@ -445,8 +445,8 @@ dev = [ { name = "copychat" }, { name = "dirty-equals" }, { name = "fastapi" }, - { name = "ipython", version = "8.36.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "8.37.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pdbpp" }, { name = "pre-commit" }, { name = "pyright" }, @@ -602,7 +602,7 @@ wheels = [ [[package]] name = "ipython" -version = "8.36.0" +version = "8.37.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11'", @@ -620,14 +620,14 @@ dependencies = [ { name = "traitlets", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/9f/d9a73710df947b7804bd9d93509463fb3a89e0ddc99c9fcc67279cddbeb6/ipython-8.36.0.tar.gz", hash = "sha256:24658e9fe5c5c819455043235ba59cfffded4a35936eefceceab6b192f7092ff", size = 5604997, upload-time = "2025-04-25T18:03:38.031Z" } +sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/d7/c1c9f371790b3a181e343c4815a361e5a0cc7d90ef6642d64ba5d05de289/ipython-8.36.0-py3-none-any.whl", hash = "sha256:12b913914d010dcffa2711505ec8be4bf0180742d97f1e5175e51f22086428c1", size = 831074, upload-time = "2025-04-25T18:03:34.951Z" }, + { url = "https://files.pythonhosted.org/packages/91/d0/274fbf7b0b12643cbbc001ce13e6a5b1607ac4929d1b11c72460152c9fc3/ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2", size = 831864, upload-time = "2025-05-31T16:39:06.38Z" }, ] [[package]] name = "ipython" -version = "9.2.0" +version = "9.3.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.11'", @@ -645,9 +645,9 @@ dependencies = [ { name = "traitlets", marker = "python_full_version >= '3.11'" }, { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/02/63a84444a7409b3c0acd1de9ffe524660e0e5d82ee473e78b45e5bfb64a4/ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b", size = 4424394, upload-time = "2025-04-25T17:55:40.498Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/09/4c7e06b96fbd203e06567b60fb41b06db606b6a82db6db7b2c85bb72a15c/ipython-9.3.0.tar.gz", hash = "sha256:79eb896f9f23f50ad16c3bc205f686f6e030ad246cc309c6279a242b14afe9d8", size = 4426460, upload-time = "2025-05-31T16:34:55.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/78/ce/5e897ee51b7d26ab4e47e5105e7368d40ce6cfae2367acdf3165396d50be/ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6", size = 604277, upload-time = "2025-04-25T17:55:37.625Z" }, + { url = "https://files.pythonhosted.org/packages/3c/99/9ed3d52d00f1846679e3aa12e2326ac7044b5e7f90dc822b60115fa533ca/ipython-9.3.0-py3-none-any.whl", hash = "sha256:1a0b6dd9221a1f5dddf725b57ac0cb6fddc7b5f470576231ae9162b9b3455a04", size = 605320, upload-time = "2025-05-31T16:34:52.154Z" }, ] [[package]] @@ -1350,15 +1350,14 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.3.5" +version = "2.3.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/5f/28f45b1ff14bee871bacafd0a97213f7ec70e389939a80c60c0fb72a9fc9/sse_starlette-2.3.5.tar.gz", hash = "sha256:228357b6e42dcc73a427990e2b4a03c023e2495ecee82e14f07ba15077e334b2", size = 17511, upload-time = "2025-05-12T18:23:52.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/f4/989bc70cb8091eda43a9034ef969b25145291f3601703b82766e5172dfed/sse_starlette-2.3.6.tar.gz", hash = "sha256:0382336f7d4ec30160cf9ca0518962905e1b69b72d6c1c995131e0a703b436e3", size = 18284, upload-time = "2025-05-30T13:34:12.914Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/48/3e49cf0f64961656402c0023edbc51844fe17afe53ab50e958a6dbbbd499/sse_starlette-2.3.5-py3-none-any.whl", hash = "sha256:251708539a335570f10eaaa21d1848a10c42ee6dc3a9cf37ef42266cdb1c52a8", size = 10233, upload-time = "2025-05-12T18:23:50.722Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/78850ac6e79af5b9508f8841b0f26aa9fd329a1ba00bf65453c2d312bcc8/sse_starlette-2.3.6-py3-none-any.whl", hash = "sha256:d49a8285b182f6e2228e2609c350398b2ca2c36216c2675d875f81e93548f760", size = 10606, upload-time = "2025-05-30T13:34:11.703Z" }, ] [[package]] From 05c87d9f8922f2f3d9c83f9f1cbcc2354adb14b8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 22:42:51 -0400 Subject: [PATCH 16/34] Update fastmcp.mdx --- docs/servers/fastmcp.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index ad2a356b0..4967d6cfa 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -35,6 +35,7 @@ The `FastMCP` constructor accepts several arguments: * `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality. * `lifespan`: (Optional) An async context manager function for server startup and shutdown logic. * `tags`: (Optional) A set of strings to tag the server itself. +* `tools`: (Optional) A list of tools to add to the server. In some cases, providing tools as a list of functions may be more convenient than using the `@mcp.tool` decorator. * `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration ## Components From 7ac2c79ad93bba47094644103e2a02473299bb50 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sat, 31 May 2025 22:49:25 -0400 Subject: [PATCH 17/34] Update fastmcp.mdx --- docs/servers/fastmcp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/servers/fastmcp.mdx b/docs/servers/fastmcp.mdx index 4967d6cfa..cfcd6452d 100644 --- a/docs/servers/fastmcp.mdx +++ b/docs/servers/fastmcp.mdx @@ -35,7 +35,7 @@ The `FastMCP` constructor accepts several arguments: * `instructions`: (Optional) Description of how to interact with this server. These instructions help clients understand the server's purpose and available functionality. * `lifespan`: (Optional) An async context manager function for server startup and shutdown logic. * `tags`: (Optional) A set of strings to tag the server itself. -* `tools`: (Optional) A list of tools to add to the server. In some cases, providing tools as a list of functions may be more convenient than using the `@mcp.tool` decorator. +* `tools`: (Optional) A list of tools (or functions to convert to tools) to add to the server. In some cases, providing tools programmatically may be more convenient than using the `@mcp.tool` decorator. * `**settings`: Keyword arguments corresponding to additional `ServerSettings` configuration ## Components From 8fb709c98c8162d5a0a01f21dedddfc21b375723 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 09:35:06 -0400 Subject: [PATCH 18/34] Simplify code for running servers in processes --- src/fastmcp/utilities/tests.py | 22 ++++-- tests/auth/test_oauth_client.py | 25 +------ tests/client/test_openapi.py | 77 +++++---------------- tests/client/test_sse.py | 36 +++------- tests/client/test_streamable_http.py | 56 +++++---------- tests/server/http/test_http_dependencies.py | 44 ++---------- 6 files changed, 70 insertions(+), 190 deletions(-) diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 4fa73006e..149182f3b 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal import uvicorn +import fastmcp from fastmcp.settings import settings from fastmcp.utilities.http import find_available_port @@ -72,14 +73,19 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No @contextmanager def run_server_in_process( - server_fn: Callable[..., None], *args + server_fn: FastMCP | Callable[..., None], + *args, + provide_host_and_port: bool = True, + **kwargs, ) -> Generator[str, None, None]: """ - Context manager that runs a Starlette app in a separate process and returns the - server URL. When the context manager is exited, the server process is killed. + Context manager that runs a FastMCP server (or a function that runs a FastMCP server) in a separate process and + returns the server URL. When the context manager is exited, the server process is killed. Args: - app: The Starlette app to run. + server_fn: The FastMCP server to run, or a function that runs a FastMCP + server. If a FastMCP server is provided, its .run() method is called + with the provided arguments and keyword arguments. Returns: The server URL. @@ -87,8 +93,14 @@ def run_server_in_process( host = "127.0.0.1" port = find_available_port() + if isinstance(server_fn, fastmcp.FastMCP): + server_fn = server_fn.run + + if provide_host_and_port: + kwargs |= {"host": host, "port": port} + proc = multiprocessing.Process( - target=server_fn, args=(host, port, *args), daemon=True + target=server_fn, args=args, kwargs=kwargs, daemon=True ) proc.start() diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index 2a1fd4c23..5d668c6dc 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -1,11 +1,9 @@ -import sys from collections.abc import Generator from unittest.mock import patch from urllib.parse import parse_qs, urlparse import httpx import pytest -import uvicorn import fastmcp.client.auth # Import module, not the function directly from fastmcp.client import Client @@ -39,30 +37,13 @@ def fastmcp_server(issuer_url: str): return server -def run_server(host: str, port: int, transport: str | None = None) -> None: - try: - # Configure OAuth provider with the actual server URL - issuer_url = f"http://{host}:{port}" - app = fastmcp_server(issuer_url).http_app() - server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) +def run_server(host: str, port: int, **kwargs) -> None: + fastmcp_server(f"http://{host}:{port}").run(host=host, port=port, **kwargs) @pytest.fixture(scope="module") def streamable_http_server() -> Generator[str, None, None]: - with run_server_in_process(run_server) as url: + with run_server_in_process(run_server, transport="streamable-http") as url: yield f"{url}/mcp" diff --git a/tests/client/test_openapi.py b/tests/client/test_openapi.py index 8ea642096..000895e87 100644 --- a/tests/client/test_openapi.py +++ b/tests/client/test_openapi.py @@ -1,9 +1,7 @@ import json -import sys from collections.abc import Generator import pytest -import uvicorn from fastapi import FastAPI, Request from fastmcp import Client, FastMCP @@ -34,75 +32,34 @@ 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: + client = Client(transport=StreamableHttpTransport(shttp_url)) + app = FastMCP.as_proxy(client) + app.run(host=host, port=port, **kwargs) + + class TestClientHeaders: - def run_shttp_server(self, host: str, port: int) -> None: - try: - app = fastmcp_server_for_headers().http_app(transport="streamable-http") - server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) - - def run_sse_server(self, host: str, port: int) -> None: - try: - app = fastmcp_server_for_headers().http_app(transport="sse") - server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) - - def run_proxy_server(self, host: str, port: int, remote_url: str) -> None: - try: - client = Client(transport=StreamableHttpTransport(remote_url)) - app = FastMCP.as_proxy(client).http_app(transport="streamable-http") - server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) - @pytest.fixture(scope="class") def shttp_server(self) -> Generator[str, None, None]: - with run_server_in_process(self.run_shttp_server) as url: + with run_server_in_process(run_server, transport="streamable-http") as url: yield f"{url}/mcp" @pytest.fixture(scope="class") def sse_server(self) -> Generator[str, None, None]: - with run_server_in_process(self.run_sse_server) as url: + with run_server_in_process(run_server, transport="sse") as url: yield f"{url}/sse" @pytest.fixture(scope="class") def proxy_server(self, shttp_server: str) -> Generator[str, None, None]: - with run_server_in_process(self.run_proxy_server, shttp_server + "/mcp") as url: + with run_server_in_process( + run_proxy_server, + shttp_url=shttp_server, + transport="streamable-http", + ) as url: yield f"{url}/mcp" async def test_client_headers_sse_resource(self, sse_server: str): diff --git a/tests/client/test_sse.py b/tests/client/test_sse.py index ebf7a0a31..39b556dda 100644 --- a/tests/client/test_sse.py +++ b/tests/client/test_sse.py @@ -63,22 +63,13 @@ def fastmcp_server(): return server -def run_server(host: str, port: int, path: str | None = None) -> None: - try: - app = fastmcp_server().http_app(transport="sse", path=path) - server = uvicorn.Server( - config=uvicorn.Config(app=app, host=host, port=port, log_level="error") - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) +def run_server(host: str, port: int, **kwargs) -> None: + fastmcp_server().run(host=host, port=port, **kwargs) @pytest.fixture(autouse=True, scope="module") def sse_server() -> Generator[str, None, None]: - with run_server_in_process(run_server) as url: + with run_server_in_process(run_server, transport="sse") as url: yield f"{url}/sse" @@ -101,22 +92,17 @@ async def test_http_headers(sse_server: str): def run_nested_server(host: str, port: int) -> None: - try: - app = fastmcp_server().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") - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) + app = fastmcp_server().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") + ) + server.run() async def test_run_server_on_path(): - with run_server_in_process(run_server, "/help") as url: + 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 diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 34723e7c4..2c4e349e3 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -63,28 +63,13 @@ def fastmcp_server(): return server -def run_server(host: str, port: int) -> None: - try: - app = fastmcp_server().http_app() - server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) +def run_server(host: str, port: int, **kwargs) -> None: + fastmcp_server().run(host=host, port=port, **kwargs) @pytest.fixture(scope="module") def streamable_http_server() -> Generator[str, None, None]: - with run_server_in_process(run_server) as url: + with run_server_in_process(run_server, transport="streamable-http") as url: yield f"{url}/mcp" @@ -111,28 +96,23 @@ async def test_http_headers(streamable_http_server: str): def run_nested_server(host: str, port: int) -> None: - try: - mcp_app = fastmcp_server().http_app(path="/final/mcp") + 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, + 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", ) - server = uvicorn.Server( - config=uvicorn.Config( - app=mount2, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) + ) + server.run() async def test_nested_streamable_http_server_resolves_correctly(): diff --git a/tests/server/http/test_http_dependencies.py b/tests/server/http/test_http_dependencies.py index 938b55c14..580ceabd3 100644 --- a/tests/server/http/test_http_dependencies.py +++ b/tests/server/http/test_http_dependencies.py @@ -1,9 +1,7 @@ import json -import sys from collections.abc import Generator import pytest -import uvicorn from fastmcp.client import Client from fastmcp.client.transports import SSETransport, StreamableHttpTransport @@ -40,53 +38,19 @@ def fastmcp_server(): return server -def run_shttp_server(host: str, port: int) -> None: - try: - app = fastmcp_server().http_app(transport="streamable-http") - server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) - - -def run_sse_server(host: str, port: int) -> None: - try: - app = fastmcp_server().http_app(transport="sse") - server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host=host, - port=port, - log_level="error", - lifespan="on", - ) - ) - server.run() - except Exception as e: - print(f"Server error: {e}") - sys.exit(1) - sys.exit(0) +def run_server(host: str, port: int, **kwargs) -> None: + fastmcp_server().run(host=host, port=port, **kwargs) @pytest.fixture(autouse=True, scope="module") def shttp_server() -> Generator[str, None, None]: - with run_server_in_process(run_shttp_server) as url: + with run_server_in_process(run_server, transport="streamable-http") as url: yield f"{url}/mcp" @pytest.fixture(autouse=True, scope="module") def sse_server() -> Generator[str, None, None]: - with run_server_in_process(run_sse_server) as url: + with run_server_in_process(run_server, transport="sse") as url: yield f"{url}/sse" From d7824f14142613fff68acf753f7500e329d5939b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 09:35:42 -0400 Subject: [PATCH 19/34] Move nested server --- tests/client/test_streamable_http.py | 40 ++++++++++++++-------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 2c4e349e3..a806f7200 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -67,6 +67,26 @@ def run_server(host: str, port: int, **kwargs) -> None: fastmcp_server().run(host=host, port=port, **kwargs) +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", + ) + ) + server.run() + + @pytest.fixture(scope="module") def streamable_http_server() -> Generator[str, None, None]: with run_server_in_process(run_server, transport="streamable-http") as url: @@ -95,26 +115,6 @@ async def test_http_headers(streamable_http_server: str): assert json_result["x-demo-header"] == "ABC" -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", - ) - ) - server.run() - - async def test_nested_streamable_http_server_resolves_correctly(): # tests patch for # https://github.com/modelcontextprotocol/python-sdk/pull/659 From 7f5744aeb1909b63033b1a640ffc71694300ca67 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 09:39:39 -0400 Subject: [PATCH 20/34] Ensure pickleable args --- src/fastmcp/utilities/tests.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py index 149182f3b..fd9e45945 100644 --- a/src/fastmcp/utilities/tests.py +++ b/src/fastmcp/utilities/tests.py @@ -10,7 +10,6 @@ from typing import TYPE_CHECKING, Any, Literal import uvicorn -import fastmcp from fastmcp.settings import settings from fastmcp.utilities.http import find_available_port @@ -73,19 +72,21 @@ def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> No @contextmanager def run_server_in_process( - server_fn: FastMCP | Callable[..., None], + server_fn: Callable[..., None], *args, provide_host_and_port: bool = True, **kwargs, ) -> Generator[str, None, None]: """ - Context manager that runs a FastMCP server (or a function that runs a FastMCP server) in a separate process and + Context manager that runs a FastMCP server in a separate process and returns the server URL. When the context manager is exited, the server process is killed. Args: - server_fn: The FastMCP server to run, or a function that runs a FastMCP - server. If a FastMCP server is provided, its .run() method is called - with the provided arguments and keyword arguments. + server_fn: The function that runs a FastMCP server. FastMCP servers are + not pickleable, so we need a function that creates and runs one. + *args: Arguments to pass to the server function. + provide_host_and_port: Whether to provide the host and port to the server function as kwargs. + **kwargs: Keyword arguments to pass to the server function. Returns: The server URL. @@ -93,9 +94,6 @@ def run_server_in_process( host = "127.0.0.1" port = find_available_port() - if isinstance(server_fn, fastmcp.FastMCP): - server_fn = server_fn.run - if provide_host_and_port: kwargs |= {"host": host, "port": port} From 20ae68bb7dce5506030498093df5c425c848a54b Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 11:13:21 -0400 Subject: [PATCH 21/34] Add basic bearer auth for server and client --- pyproject.toml | 1 + src/fastmcp/client/auth.py | 9 + src/fastmcp/server/auth/bearer.py | 256 ++++++++++++ src/fastmcp/server/auth/providers/__init__.py | 0 src/fastmcp/server/auth/providers/bearer.py | 359 +++++++++++++++++ .../in_memory.py} | 7 +- src/fastmcp/server/dependencies.py | 10 + tests/auth/providers/test_bearer.py | 366 ++++++++++++++++++ tests/auth/test_oauth_client.py | 4 +- uv.lock | 58 +++ 10 files changed, 1067 insertions(+), 3 deletions(-) create mode 100644 src/fastmcp/server/auth/bearer.py create mode 100644 src/fastmcp/server/auth/providers/__init__.py create mode 100644 src/fastmcp/server/auth/providers/bearer.py rename src/fastmcp/server/auth/{in_memory_provider.py => providers/in_memory.py} (98%) create mode 100644 tests/auth/providers/test_bearer.py diff --git a/pyproject.toml b/pyproject.toml index a55e60254..35b538b58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dev = [ "ipython>=8.12.3", "pdbpp>=0.10.3", "pre-commit", + "pyinstrument>=5.0.2", "pyright>=1.1.389", "pytest>=8.3.3", "pytest-asyncio>=0.23.5", diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth.py index 43df9d442..28c2bba84 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth.py @@ -392,3 +392,12 @@ def OAuth( ) return oauth_provider + + +class BearerAuth(httpx.Auth): + def __init__(self, token: str): + self.token = token + + def auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {self.token}" + yield request diff --git a/src/fastmcp/server/auth/bearer.py b/src/fastmcp/server/auth/bearer.py new file mode 100644 index 000000000..729fcf1dc --- /dev/null +++ b/src/fastmcp/server/auth/bearer.py @@ -0,0 +1,256 @@ +""" +Simple JWT Bearer Token validation for hosted MCP servers. + +Uses RS256 (asymmetric) where your control plane signs with a private key +and hosted MCP servers validate with the corresponding public key. + +Example usage: +# Static public key +provider = BearerTokenValidatorProvider( + public_key='''-----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY-----''', + issuer="https://auth.yourservice.com" +) + +# Or JWKS URI (recommended for production - allows key rotation) +provider = BearerTokenValidatorProvider( + jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", + issuer="https://auth.yourservice.com" +) +""" + +import time +from typing import Any + +import httpx +from authlib.jose import JsonWebKey, JsonWebToken +from authlib.jose.errors import JoseError +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + RefreshToken, +) +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthToken, +) + +from fastmcp.server.auth.auth import ( + ClientRegistrationOptions, + OAuthProvider, + RevocationOptions, +) + + +class BearerTokenValidatorProvider(OAuthProvider): + """ + Simple JWT Bearer Token validator for hosted MCP servers. + Uses RS256 asymmetric encryption. Supports either static public key + or JWKS URI for key rotation. + """ + + def __init__( + self, + issuer: str, + public_key: str | None = None, + jwks_uri: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + ): + """ + Initialize the provider. + + Args: + issuer: Expected issuer claim (your control plane) + public_key: RSA public key in PEM format (for static key) + jwks_uri: URI to fetch keys from (for key rotation) + audience: Expected audience claim (optional) + required_scopes: List of required scopes for access + """ + if not (public_key or jwks_uri): + raise ValueError("Either public_key or jwks_uri must be provided") + if public_key and jwks_uri: + raise ValueError("Provide either public_key or jwks_uri, not both") + + super().__init__( + issuer_url=issuer, + client_registration_options=ClientRegistrationOptions(enabled=False), + revocation_options=RevocationOptions(enabled=False), + required_scopes=required_scopes, + ) + + self.issuer = issuer + self.audience = audience + self.public_key = public_key + self.jwks_uri = jwks_uri + self.jwt = JsonWebToken(["RS256"]) + + # Simple JWKS cache + self._jwks_cache: dict[str, str] = {} + self._jwks_cache_time: float = 0 + self._cache_ttl = 3600 # 1 hour + + async def _get_verification_key(self, token: str) -> str: + """Get the verification key for the token.""" + if self.public_key: + return self.public_key + + # Extract kid from token header for JWKS lookup + try: + import base64 + import json + + header_b64 = token.split(".")[0] + header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding + header = json.loads(base64.urlsafe_b64decode(header_b64)) + kid = header.get("kid") + + if not kid: + raise ValueError("Token missing key ID (kid)") + + return await self._get_jwks_key(kid) + + except Exception as e: + raise ValueError(f"Failed to extract key ID from token: {e}") + + async def _get_jwks_key(self, kid: str) -> str: + """Fetch key from JWKS with simple caching.""" + if not self.jwks_uri: + raise ValueError("JWKS URI not configured") + + current_time = time.time() + + # Check cache + if ( + current_time - self._jwks_cache_time < self._cache_ttl + and kid in self._jwks_cache + ): + return self._jwks_cache[kid] + + # Fetch JWKS + try: + async with httpx.AsyncClient() as client: + response = await client.get(self.jwks_uri) + response.raise_for_status() + jwks_data = response.json() + + # Cache all keys + self._jwks_cache = {} + for key_data in jwks_data.get("keys", []): + key_kid = key_data.get("kid") + if key_kid: + jwk = JsonWebKey.import_key(key_data) + self._jwks_cache[key_kid] = jwk.get_public_key() + + self._jwks_cache_time = current_time + + if kid not in self._jwks_cache: + raise ValueError(f"Key ID '{kid}' not found in JWKS") + + return self._jwks_cache[kid] + + except Exception as e: + raise ValueError(f"Failed to fetch JWKS: {e}") + + async def load_access_token(self, token: str) -> AccessToken | None: + """ + Validates the provided JWT bearer token. + + Args: + token: The JWT token string to validate + + Returns: + AccessToken object if valid, None if invalid or expired + """ + try: + # Get verification key (static or from JWKS) + verification_key = await self._get_verification_key(token) + + # Decode and verify the JWT token + claims = self.jwt.decode(token, verification_key) + + # Validate expiration + exp = claims.get("exp") + if exp and exp < time.time(): + return None + + # Validate issuer + if claims.get("iss") != self.issuer: + return None + + # Validate audience if configured + if self.audience: + aud = claims.get("aud") + if isinstance(aud, list): + if self.audience not in aud: + return None + elif aud != self.audience: + return None + + # Extract claims + client_id = claims.get("sub") or claims.get("client_id") or "unknown" + scopes = self._extract_scopes(claims) + + return AccessToken( + token=token, + client_id=str(client_id), + scopes=scopes, + expires_at=int(exp) if exp else None, + ) + + except JoseError: + return None + except Exception: + return None + + def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: + """Extract scopes from JWT claims.""" + scope_claim = claims.get("scope", "") + if isinstance(scope_claim, str): + return scope_claim.split() + elif isinstance(scope_claim, list): + return scope_claim + return [] + + # --- Unused OAuth server methods --- + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + raise NotImplementedError("Client management not supported") + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + raise NotImplementedError("Client registration not supported") + + async def authorize( + self, client: OAuthClientInformationFull, params: AuthorizationParams + ) -> str: + raise NotImplementedError("Authorization flow not supported") + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + raise NotImplementedError("Authorization code flow not supported") + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + raise NotImplementedError("Authorization code exchange not supported") + + async def load_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: str + ) -> RefreshToken | None: + raise NotImplementedError("Refresh token flow not supported") + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + raise NotImplementedError("Refresh token exchange not supported") + + async def revoke_token( + self, + token: AccessToken | RefreshToken, + ) -> None: + raise NotImplementedError("Token revocation not supported") diff --git a/src/fastmcp/server/auth/providers/__init__.py b/src/fastmcp/server/auth/providers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py new file mode 100644 index 000000000..3cedfee7b --- /dev/null +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -0,0 +1,359 @@ +""" +Simple JWT Bearer Token validation for hosted MCP servers. + +Uses RS256 (asymmetric) where your control plane signs with a private key +and hosted MCP servers validate with the corresponding public key. + +Example usage: +# Static public key +provider = BearerTokenValidatorProvider( + public_key='''-----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... + -----END PUBLIC KEY-----''', + issuer="https://auth.yourservice.com" +) + +# Or JWKS URI (recommended for production - allows key rotation) +provider = BearerTokenValidatorProvider( + jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", + issuer="https://auth.yourservice.com" +) +""" + +import time +from dataclasses import dataclass +from typing import Any + +import httpx +from authlib.jose import JsonWebKey, JsonWebToken +from authlib.jose.errors import JoseError +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + RefreshToken, +) +from mcp.shared.auth import ( + OAuthClientInformationFull, + OAuthToken, +) +from pydantic import SecretStr + +from fastmcp.server.auth.auth import ( + ClientRegistrationOptions, + OAuthProvider, + RevocationOptions, +) + + +@dataclass(frozen=True, kw_only=True, repr=False) +class RSAKeyPair: + private_key: SecretStr + public_key: str + + @classmethod + def generate(cls) -> "RSAKeyPair": + """ + Generate an RSA key pair for testing. + + Returns: + tuple: (private_key_pem, public_key_pem) + """ + # Generate private key + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + + # Get public key + public_key = private_key.public_key() + + # Serialize private key to PEM format + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("utf-8") + + # Serialize public key to PEM format + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + + return cls( + private_key=SecretStr(private_pem), + public_key=public_pem, + ) + + def create_token( + self, + subject: str = "fastmcp-user", + issuer: str = "https://fastmcp.example.com", + audience: str | None = None, + scopes: list[str] | None = None, + expires_in_seconds: int = 3600, + additional_claims: dict[str, Any] | None = None, + ) -> str: + """ + Generate a test JWT token for testing purposes. + + Args: + private_key_pem: RSA private key in PEM format + subject: Subject claim (usually user ID) + issuer: Issuer claim + audience: Audience claim (optional) + scopes: List of scopes to include + expires_in_seconds: Token expiration time in seconds + additional_claims: Any additional claims to include + + Returns: + Signed JWT token string + """ + jwt = JsonWebToken(["RS256"]) + + now = int(time.time()) + + # Build payload + payload = { + "iss": issuer, + "sub": subject, + "iat": now, + "exp": now + expires_in_seconds, + } + + if audience: + payload["aud"] = audience + + if scopes: + payload["scope"] = " ".join(scopes) + + if additional_claims: + payload.update(additional_claims) + + # Create header + header = {"alg": "RS256"} + + # Sign and return token + token_bytes = jwt.encode( + header, + payload, + key=self.private_key.get_secret_value(), + ) + + return token_bytes.decode("utf-8") + + +class BearerAuthProvider(OAuthProvider): + """ + Simple JWT Bearer Token validator for hosted MCP servers. + Uses RS256 asymmetric encryption. Supports either static public key + or JWKS URI for key rotation. + """ + + def __init__( + self, + issuer: str | None = None, + public_key: str | None = None, + jwks_uri: str | None = None, + audience: str | None = None, + required_scopes: list[str] | None = None, + ): + """ + Initialize the provider. + + Args: + issuer: Expected issuer claim (your control plane) + public_key: RSA public key in PEM format (for static key) + jwks_uri: URI to fetch keys from (for key rotation) + audience: Expected audience claim (optional) + required_scopes: List of required scopes for access + """ + if not (public_key or jwks_uri): + raise ValueError("Either public_key or jwks_uri must be provided") + if public_key and jwks_uri: + raise ValueError("Provide either public_key or jwks_uri, not both") + + super().__init__( + issuer_url=issuer or "http://fastmcp.example.com", + client_registration_options=ClientRegistrationOptions(enabled=False), + revocation_options=RevocationOptions(enabled=False), + required_scopes=required_scopes, + ) + + self.issuer = issuer + self.audience = audience + self.public_key = public_key + self.jwks_uri = jwks_uri + self.jwt = JsonWebToken(["RS256"]) + + # Simple JWKS cache + self._jwks_cache: dict[str, str] = {} + self._jwks_cache_time: float = 0 + self._cache_ttl = 3600 # 1 hour + + async def _get_verification_key(self, token: str) -> str: + """Get the verification key for the token.""" + if self.public_key: + return self.public_key + + # Extract kid from token header for JWKS lookup + try: + import base64 + import json + + header_b64 = token.split(".")[0] + header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding + header = json.loads(base64.urlsafe_b64decode(header_b64)) + kid = header.get("kid") + + if not kid: + raise ValueError("Token missing key ID (kid)") + + return await self._get_jwks_key(kid) + + except Exception as e: + raise ValueError(f"Failed to extract key ID from token: {e}") + + async def _get_jwks_key(self, kid: str) -> str: + """Fetch key from JWKS with simple caching.""" + if not self.jwks_uri: + raise ValueError("JWKS URI not configured") + + current_time = time.time() + + # Check cache + if ( + current_time - self._jwks_cache_time < self._cache_ttl + and kid in self._jwks_cache + ): + return self._jwks_cache[kid] + + # Fetch JWKS + try: + async with httpx.AsyncClient() as client: + response = await client.get(self.jwks_uri) + response.raise_for_status() + jwks_data = response.json() + + # Cache all keys + self._jwks_cache = {} + for key_data in jwks_data.get("keys", []): + key_kid = key_data.get("kid") + if key_kid: + jwk = JsonWebKey.import_key(key_data) + self._jwks_cache[key_kid] = jwk.get_public_key() + + self._jwks_cache_time = current_time + + if kid not in self._jwks_cache: + raise ValueError(f"Key ID '{kid}' not found in JWKS") + + return self._jwks_cache[kid] + + except Exception as e: + raise ValueError(f"Failed to fetch JWKS: {e}") + + async def load_access_token(self, token: str) -> AccessToken | None: + """ + Validates the provided JWT bearer token. + + Args: + token: The JWT token string to validate + + Returns: + AccessToken object if valid, None if invalid or expired + """ + try: + # Get verification key (static or from JWKS) + verification_key = await self._get_verification_key(token) + + # Decode and verify the JWT token + claims = self.jwt.decode(token, verification_key) + + # Validate expiration + exp = claims.get("exp") + if exp and exp < time.time(): + return None + + # Validate issuer + if self.issuer: + if claims.get("iss") != self.issuer: + return None + + # Validate audience if configured + if self.audience: + aud = claims.get("aud") + if isinstance(aud, list): + if self.audience not in aud: + return None + elif aud != self.audience: + return None + + # Extract claims - prefer client_id over sub for OAuth application identification + client_id = claims.get("client_id") or claims.get("sub") or "unknown" + scopes = self._extract_scopes(claims) + + return AccessToken( + token=token, + client_id=str(client_id), + scopes=scopes, + expires_at=int(exp) if exp else None, + ) + + except JoseError: + return None + except Exception: + return None + + def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: + """Extract scopes from JWT claims.""" + scope_claim = claims.get("scope", "") + if isinstance(scope_claim, str): + return scope_claim.split() + elif isinstance(scope_claim, list): + return scope_claim + return [] + + # --- Unused OAuth server methods --- + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + raise NotImplementedError("Client management not supported") + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + raise NotImplementedError("Client registration not supported") + + async def authorize( + self, client: OAuthClientInformationFull, params: AuthorizationParams + ) -> str: + raise NotImplementedError("Authorization flow not supported") + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + raise NotImplementedError("Authorization code flow not supported") + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + raise NotImplementedError("Authorization code exchange not supported") + + async def load_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: str + ) -> RefreshToken | None: + raise NotImplementedError("Refresh token flow not supported") + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + raise NotImplementedError("Refresh token exchange not supported") + + async def revoke_token( + self, + token: AccessToken | RefreshToken, + ) -> None: + raise NotImplementedError("Token revocation not supported") diff --git a/src/fastmcp/server/auth/in_memory_provider.py b/src/fastmcp/server/auth/providers/in_memory.py similarity index 98% rename from src/fastmcp/server/auth/in_memory_provider.py rename to src/fastmcp/server/auth/providers/in_memory.py index 59ac0d2ad..6494ef18b 100644 --- a/src/fastmcp/server/auth/in_memory_provider.py +++ b/src/fastmcp/server/auth/providers/in_memory.py @@ -1,3 +1,8 @@ +""" +This is a simple in-memory OAuth provider for testing purposes. +It simulates the OAuth 2.0 flow locally without external calls. +""" + import secrets import time @@ -43,7 +48,7 @@ class InMemoryOAuthProvider(OAuthProvider): required_scopes: list[str] | None = None, ): super().__init__( - issuer_url or "https://example.com", + issuer_url=issuer_url or "http://fastmcp.example.com", service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, revocation_options=revocation_options, diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index e2d279dc5..572af5282 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import TYPE_CHECKING, ParamSpec, TypeVar +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken from starlette.requests import Request if TYPE_CHECKING: @@ -10,6 +12,14 @@ if TYPE_CHECKING: P = ParamSpec("P") R = TypeVar("R") +__all__ = [ + "get_context", + "get_http_request", + "get_http_headers", + "get_access_token", + "AccessToken", +] + # --- Context --- diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py new file mode 100644 index 000000000..8f81d4339 --- /dev/null +++ b/tests/auth/providers/test_bearer.py @@ -0,0 +1,366 @@ +from collections.abc import Generator + +import httpx +import pytest + +from fastmcp import Client, FastMCP +from fastmcp.client.auth import BearerAuth +from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair +from fastmcp.utilities.tests import run_server_in_process + + +@pytest.fixture(scope="module") +def rsa_key_pair() -> RSAKeyPair: + return RSAKeyPair.generate() + + +@pytest.fixture(scope="module") +def bearer_token(rsa_key_pair: RSAKeyPair) -> str: + return rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + +@pytest.fixture +def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider: + return BearerAuthProvider( + public_key=rsa_key_pair.public_key, + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + +def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> str: + mcp = FastMCP( + auth=BearerAuthProvider( + issuer="https://test.example.com", + public_key=public_key, + ) + ) + + @mcp.tool() + def add(a: int, b: int) -> int: + return a + b + + mcp.run(host=host, port=port, **kwargs) + + +@pytest.fixture(scope="module") +def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]: + with run_server_in_process( + run_mcp_server, public_key=rsa_key_pair.public_key, transport="streamable-http" + ) as url: + yield f"{url}/mcp" + + +class TestRSAKeyPair: + def test_generate_key_pair(self): + """Test RSA key pair generation.""" + key_pair = RSAKeyPair.generate() + + assert key_pair.private_key is not None + assert key_pair.public_key is not None + + # Check that keys are in PEM format + private_pem = key_pair.private_key.get_secret_value() + public_pem = key_pair.public_key.get_secret_value() + + assert "-----BEGIN PRIVATE KEY-----" in private_pem + assert "-----END PRIVATE KEY-----" in private_pem + assert "-----BEGIN PUBLIC KEY-----" in public_pem + assert "-----END PUBLIC KEY-----" in public_pem + + def test_create_basic_token(self, rsa_key_pair: RSAKeyPair): + """Test basic token creation.""" + token = rsa_key_pair.create_token( + subject="test-user", issuer="https://test.example.com" + ) + + assert isinstance(token, str) + assert len(token.split(".")) == 3 # JWT has 3 parts + + def test_create_token_with_scopes(self, rsa_key_pair: RSAKeyPair): + """Test token creation with scopes.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + scopes=["read", "write"], + ) + + assert isinstance(token, str) + # We'll validate the scopes in the BearerToken tests + + +class TestBearerToken: + def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair): + """Test provider initialization with public key.""" + provider = BearerAuthProvider( + public_key=rsa_key_pair.public_key, issuer="https://test.example.com" + ) + + assert provider.issuer == "https://test.example.com" + assert provider.public_key is not None + assert provider.jwks_uri is None + + def test_initialization_with_jwks_uri(self): + """Test provider initialization with JWKS URI.""" + provider = BearerAuthProvider( + jwks_uri="https://test.example.com/.well-known/jwks.json", + issuer="https://test.example.com", + ) + + assert provider.issuer == "https://test.example.com" + assert provider.jwks_uri == "https://test.example.com/.well-known/jwks.json" + assert provider.public_key is None + + def test_initialization_requires_key_or_uri(self): + """Test that either public_key or jwks_uri is required.""" + with pytest.raises( + ValueError, match="Either public_key or jwks_uri must be provided" + ): + BearerAuthProvider(issuer="https://test.example.com") + + def test_initialization_rejects_both_key_and_uri(self, rsa_key_pair: RSAKeyPair): + """Test that both public_key and jwks_uri cannot be provided.""" + with pytest.raises( + ValueError, match="Provide either public_key or jwks_uri, not both" + ): + BearerAuthProvider( + public_key=rsa_key_pair.public_key, + jwks_uri="https://test.example.com/.well-known/jwks.json", + issuer="https://test.example.com", + ) + + @pytest.mark.asyncio + async def test_valid_token_validation( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test validation of a valid token.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + scopes=["read", "write"], + ) + + access_token = await bearer_provider.load_access_token(token) + + assert access_token is not None + assert access_token.client_id == "test-user" + assert "read" in access_token.scopes + assert "write" in access_token.scopes + assert access_token.expires_at is not None + + @pytest.mark.asyncio + async def test_expired_token_rejection( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test rejection of expired tokens.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + expires_in_seconds=-3600, # Expired 1 hour ago + ) + + access_token = await bearer_provider.load_access_token(token) + assert access_token is None + + @pytest.mark.asyncio + async def test_invalid_issuer_rejection( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test rejection of tokens with invalid issuer.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://evil.example.com", # Wrong issuer + audience="https://api.example.com", + ) + + access_token = await bearer_provider.load_access_token(token) + assert access_token is None + + @pytest.mark.asyncio + async def test_invalid_audience_rejection( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test rejection of tokens with invalid audience.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://wrong-api.example.com", # Wrong audience + ) + + access_token = await bearer_provider.load_access_token(token) + assert access_token is None + + @pytest.mark.asyncio + async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair): + """Test that issuer validation is skipped when provider has no issuer configured.""" + provider = BearerAuthProvider( + public_key=rsa_key_pair.public_key, + issuer=None, # No issuer validation + ) + + token = rsa_key_pair.create_token( + subject="test-user", issuer="https://any.example.com" + ) + + access_token = await provider.load_access_token(token) + assert access_token is not None + + @pytest.mark.asyncio + async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair): + """Test that audience validation is skipped when provider has no audience configured.""" + provider = BearerAuthProvider( + public_key=rsa_key_pair.public_key, + issuer="https://test.example.com", + audience=None, # No audience validation + ) + + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://any-api.example.com", + ) + + access_token = await provider.load_access_token(token) + assert access_token is not None + + @pytest.mark.asyncio + async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair): + """Test validation with multiple audiences in token.""" + provider = BearerAuthProvider( + public_key=rsa_key_pair.public_key, + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + additional_claims={ + "aud": ["https://api.example.com", "https://other-api.example.com"] + }, + ) + + access_token = await provider.load_access_token(token) + assert access_token is not None + + @pytest.mark.asyncio + async def test_scope_extraction_string( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test scope extraction from space-separated string.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + scopes=["read", "write", "admin"], + ) + + access_token = await bearer_provider.load_access_token(token) + + assert access_token is not None + assert set(access_token.scopes) == {"read", "write", "admin"} + + @pytest.mark.asyncio + async def test_scope_extraction_list( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test scope extraction from list format.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + additional_claims={"scope": ["read", "write"]}, # List format + ) + + access_token = await bearer_provider.load_access_token(token) + + assert access_token is not None + assert set(access_token.scopes) == {"read", "write"} + + @pytest.mark.asyncio + async def test_no_scopes( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test token with no scopes.""" + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + # No scopes + ) + + access_token = await bearer_provider.load_access_token(token) + + assert access_token is not None + assert access_token.scopes == [] + + @pytest.mark.asyncio + async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider): + """Test rejection of malformed tokens.""" + malformed_tokens = [ + "not.a.jwt", + "too.many.parts.here.invalid", + "invalid-token", + "", + "header.body", # Missing signature + ] + + for token in malformed_tokens: + access_token = await bearer_provider.load_access_token(token) + assert access_token is None + + @pytest.mark.asyncio + async def test_invalid_signature_rejection( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test rejection of tokens with invalid signatures.""" + # Create a token with a different key pair + other_key_pair = RSAKeyPair.generate() + token = other_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await bearer_provider.load_access_token(token) + assert access_token is None + + @pytest.mark.asyncio + async def test_client_id_fallback( + self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider + ): + """Test client_id extraction with fallback logic.""" + # Test with explicit client_id claim + token = rsa_key_pair.create_token( + subject="user123", + issuer="https://test.example.com", + audience="https://api.example.com", + additional_claims={"client_id": "app456"}, + ) + + access_token = await bearer_provider.load_access_token(token) + assert access_token is not None + assert access_token.client_id == "app456" # Should prefer client_id over sub + + +class TestFastMCPBearerAuth: + def test_bearer_auth(self): + mcp = FastMCP( + auth=BearerAuthProvider(issuer="https://test.example.com", public_key="abc") + ) + assert isinstance(mcp.auth, BearerAuthProvider) + + async def test_unauthorized_access(self, mcp_server_url: str): + with pytest.raises(httpx.HTTPStatusError, match="401"): + async with Client(mcp_server_url) as client: + await client.ping() + + async def test_authorized_access(self, mcp_server_url: str, bearer_token): + async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client: + await client.ping() diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index 5d668c6dc..71db7fe47 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -9,7 +9,7 @@ import fastmcp.client.auth # Import module, not the function directly from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions -from fastmcp.server.auth.in_memory_provider import InMemoryOAuthProvider as InMemory +from fastmcp.server.auth.providers.in_memory import InMemoryOAuthProvider from fastmcp.server.server import FastMCP from fastmcp.utilities.tests import run_server_in_process @@ -18,7 +18,7 @@ def fastmcp_server(issuer_url: str): """Create a FastMCP server with OAuth authentication.""" server = FastMCP( "TestServer", - auth=InMemory( + auth=InMemoryOAuthProvider( issuer_url=issuer_url, client_registration_options=ClientRegistrationOptions(enabled=True), ), diff --git a/uv.lock b/uv.lock index ae92d334e..e1cd22726 100644 --- a/uv.lock +++ b/uv.lock @@ -449,6 +449,7 @@ dev = [ { name = "ipython", version = "9.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pdbpp" }, { name = "pre-commit" }, + { name = "pyinstrument" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -482,6 +483,7 @@ dev = [ { name = "ipython", specifier = ">=8.12.3" }, { name = "pdbpp", specifier = ">=0.10.3" }, { name = "pre-commit" }, + { name = "pyinstrument", specifier = ">=5.0.2" }, { name = "pyright", specifier = ">=1.1.389" }, { name = "pytest", specifier = ">=8.3.3" }, { name = "pytest-asyncio", specifier = ">=0.23.5" }, @@ -998,6 +1000,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293, upload-time = "2025-01-06T17:26:25.553Z" }, ] +[[package]] +name = "pyinstrument" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/d0/665828770e8fcd5c50880dc83f03811f814d6260bc6a8068dca0a520e68a/pyinstrument-5.0.2.tar.gz", hash = "sha256:e466033ead16a48ffa8bedbd633b90d416fa772b3b22f61226882ace0371f5f3", size = 263930, upload-time = "2025-05-24T15:47:13.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/25/f64d0be5f574d2df9ddac3e7a381863f92d8ad30170b1a9de0cf805f4318/pyinstrument-5.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1aeaf6b39ad40b3f03bea5fa3a9bd453a92aeb721dde29c1597f842ed9c8566a", size = 129638, upload-time = "2025-05-24T15:45:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/6e/b8/bc6657f91a8d2f7cf58b0993aa4e6cf20e027b53aca65c2464a50738d711/pyinstrument-5.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d734bd236d00e0e7f950019c689eaba1c9dd15e355867d8926c8b18b6077b221", size = 122220, upload-time = "2025-05-24T15:45:22.4Z" }, + { url = "https://files.pythonhosted.org/packages/63/5f/9a7edf13333015a9ccfd3fcf5c75ea793fbb30b153aebf6c6ace40a607b2/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:520208a9b6c3985473aa9c3f30875ae5e78e77a81081df1d8aeb4fd8b4caf197", size = 146928, upload-time = "2025-05-24T15:45:23.802Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f9/f7d7b28c9038f1a570e96c8eea2a9ffeeb3ee9e75cfc74a370554776f1a6/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75e115b759288b8d65a0bf31a34a542ae102c58ef407e0614a43e0c39d261875", size = 157136, upload-time = "2025-05-24T15:45:25.629Z" }, + { url = "https://files.pythonhosted.org/packages/db/ee/aa99f275b3c5f0f32ccd37f77cb64e57597a1f26280aec03a50d2158eab7/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:091f93e6787c485a7ddf670608c00448e858a056677fc25ce349f8e44d6a9e54", size = 144680, upload-time = "2025-05-24T15:45:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/cd7300a5e099c4ad971a647ea8fb9bd081482a9e5751479034e206cd1f69/pyinstrument-5.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b07971afa2652cb4f2bdcffaef11aefa32b5384c0cfb32acf9955e96dd8df8", size = 145624, upload-time = "2025-05-24T15:45:28.517Z" }, + { url = "https://files.pythonhosted.org/packages/30/59/1957e2ca2277ecc69e247383527df331002e23940d5b0a79fc5f3b870d60/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bcb28a21b80eea5986eb5cb3180689b1d489b7c6fddf34e1f4df1f95d467ad", size = 145901, upload-time = "2025-05-24T15:45:30.365Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/396ebdf387cde376ac4b70d52f3df07374f2501ac4c09992dadf641cd71f/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:80d28162070ff40c6d2ac7dc15b933ba20ef49e891a2e650cd2b91d30cd262b2", size = 145355, upload-time = "2025-05-24T15:45:31.859Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fec77476a9b4a316861b29f14cd0962871ad5c54c21e41c540f7c18c950c/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c75e52a9bf76f084ba074323835cba4927ab3e572adfc96439698b097e523780", size = 145008, upload-time = "2025-05-24T15:45:33.417Z" }, + { url = "https://files.pythonhosted.org/packages/fd/75/dcd391ca2790b32e41bbd49ad33626e85eb1ce00116b273d5e1d99b3e829/pyinstrument-5.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ccefdd7dd938548ada43c95b24c42ec57e258ac7994a5ec7e4cc934fa4f1743b", size = 145396, upload-time = "2025-05-24T15:45:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5c/64e026ccf2c7908d10882955993e73ac35a1a77426bde2617973deeda07c/pyinstrument-5.0.2-cp310-cp310-win32.whl", hash = "sha256:6b617fb024c244738aa2f6b8c2a25853eac765360ac91062578bbbcc8e22ebfe", size = 123419, upload-time = "2025-05-24T15:45:36.276Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7c/7d221db96d461c7d28897499bdad55a8ae5ded983f60743bdfbf17438c20/pyinstrument-5.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6788c8f93c1a6e0ad8d0ccde1631d17eca3839945d0fa4d506cf5d4bd7a26b77", size = 124299, upload-time = "2025-05-24T15:45:37.642Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f2/b3f2416740be762fdfb052b63e1d85591682fa1d2ea6ee1b10db774f6350/pyinstrument-5.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0eec7a263cc1ccfb101594e13256115366338fee2a156be4172fe5315f71ec45", size = 129386, upload-time = "2025-05-24T15:45:39.429Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fa/a55b0bf911041b51d2a7a0e8a3feef5ed5ddb48ff0943fc667079955c14c/pyinstrument-5.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddd5effefb470d7f1886dc16467501b866e3b5883cf74773f13179e718b28393", size = 122100, upload-time = "2025-05-24T15:45:41.253Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e1/c42b94c795bc89d5a486ad7ef349fe3b7a8c3a4e730c09b5fa54af616a6b/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e7458a6aa4048c1703354fc8a4a3c8b59d27b1409aafb707cf339d3c0bc794c", size = 145385, upload-time = "2025-05-24T15:45:43.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/41/b511141cc336ffeac284cce7d121f05802ffea4ab2c19df8869adda49743/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2373dd699711463011ec14e4918427a777f7ab73b31ae374d960725dbd5d5a28", size = 156093, upload-time = "2025-05-24T15:45:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/4a7bc4f1c60d4886efb7397fd5bdcc7e537d01ec7372824cd834fff967a1/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:38ef498fbe71c2bbd11247b71e722290da93a367d88a5a8e0f66f6cc764c2b60", size = 143136, upload-time = "2025-05-24T15:45:46.469Z" }, + { url = "https://files.pythonhosted.org/packages/d8/69/0ac06cf609153fc5eb30ccc0071ce300a181f422836ca7ce8cd431ac3ab4/pyinstrument-5.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a58a8a50f0cb3ee1c2e43ffec51bf48f48945e141feed7ccd9194917b97fe5b", size = 144077, upload-time = "2025-05-24T15:45:48.333Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/12bd82822393f708e5da8f6c0b82def3f0cbe1f4fbd72a082688c583d7fa/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad2a97c79ecf0e610df292abb5c46d01a4f99778598881d6e918650fa39801b6", size = 144545, upload-time = "2025-05-24T15:45:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/c9/62/40e7511fa46247ca56734d34e2d2eb6b14390c72b155255ecd1b2288d02d/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:57ec0277042ee198eb749b76a975fe60f006cd51ea0c7ce3054c937577d19315", size = 144010, upload-time = "2025-05-24T15:45:52.256Z" }, + { url = "https://files.pythonhosted.org/packages/82/77/6d40880dc46a6243951ad7cd50a77f26f6ad126b80d803616934efccf539/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:73d34047266f27acb67218e331288c0241cf0080fe4b87dfad5596236c71abd7", size = 143746, upload-time = "2025-05-24T15:45:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/9b/a2/08b056d2420199dab877c665ed45bb685863dc5b83d31b2c4311430b2bbd/pyinstrument-5.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cfdc23284a8e2f27637b357c226a15d52b96608d9dde187b68dfe33a947f4908", size = 143928, upload-time = "2025-05-24T15:45:55.103Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/bab336f70cd5f798d7fa21ec92784b99d3b2df0b5c1736a64fdaa4521004/pyinstrument-5.0.2-cp311-cp311-win32.whl", hash = "sha256:3e6fa135aee6af2c608e912d8d07906bbac3c5e564d94f92721831a957297c26", size = 123395, upload-time = "2025-05-24T15:45:56.469Z" }, + { url = "https://files.pythonhosted.org/packages/f2/15/8a7ac268ffe913aa64bb42ad43315dd0fc3ac493d451a50d4431ecb736c2/pyinstrument-5.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:6317df42a98a8074ccd25af5482312ec59a1f27c05dab408eb3c7b2081242733", size = 124198, upload-time = "2025-05-24T15:45:57.814Z" }, + { url = "https://files.pythonhosted.org/packages/95/36/4afdffbc4fd77dd0155c8943101f175e701ba00cb374c5e84e64790a2a32/pyinstrument-5.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d0b680ef269b528d8dcd8151362fba9683b0ac22ffe74cc8161c33b53c65b899", size = 129527, upload-time = "2025-05-24T15:45:59.216Z" }, + { url = "https://files.pythonhosted.org/packages/96/fe/7ea5af73d65f8f22585005f6e2ce1016fb3145a8ecc1ded51f965c2e98cc/pyinstrument-5.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1c70b50ec90ae793b74733a6fc992723c6ee27c0fcb7d99848239316ded61189", size = 122068, upload-time = "2025-05-24T15:46:01.05Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d2/cf8f3b8fde3f3b6768f8407c681fb57e7b5a5bf5e7450a9fbec15164987b/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aae5f4f78515009f72393fdb271a15861534a586401383785f823cf8f60aa02", size = 146679, upload-time = "2025-05-24T15:46:02.841Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/6c00273778596560c7033cfee34aab07da6009f32c5a4dbcc35b64700e73/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3aec8bc3d1c064ff849ca3568d6b0a7cfa0162d590a9d4d250c7118d09518b22", size = 157606, upload-time = "2025-05-24T15:46:04.551Z" }, + { url = "https://files.pythonhosted.org/packages/4c/cc/ec099f566e381f8e5db21d9523dd97b3255047813da57481ab3f45436089/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28d87fac2bc0fed802b14a26982440f36c85dc53f303530ff7665a6e470315bb", size = 144317, upload-time = "2025-05-24T15:46:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/37/a7/e2e54bf6d996b3c807534dbc4fe270f373660b89871c63965d3f895c285d/pyinstrument-5.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b9caac53c7eda8187ed122d4f7fcc6e3392f04c583d6d70b373351cede2b829", size = 145622, upload-time = "2025-05-24T15:46:07.334Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c6/0b084ddf8d836076e04912ea83ccae0f83bf4897d0168b0fd7684efdc2a4/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8124419e8731a7bdbb9f7f885a8956806a4e9ab9dd19294f8a99e74c0bbdd327", size = 145645, upload-time = "2025-05-24T15:46:09.236Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4d/3e542c5986cc30bc86c304492f4696e58dc03d1816d35c5b2cabfac1d01e/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9990d9bd05fbb4fa83f24f0a62989b8e0a3ac15ff0fa19b49348c8ef5f9db50a", size = 145619, upload-time = "2025-05-24T15:46:10.643Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a7/1e4664bf5ada1cff56852d10954b1ff5a39dad17b9b98a2f27054a0c0d95/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:1dc35f3d200866a43d4bc7570799a405f001591c8f19a30eb7a983a717c1e1f7", size = 145049, upload-time = "2025-05-24T15:46:12.019Z" }, + { url = "https://files.pythonhosted.org/packages/fb/59/08a5237c8d1343842ac9ed3c661dce40c450f1750128fd4789ad80539253/pyinstrument-5.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a335a40d0ba1fe3658ef1a5ff2fc7a6870905828014645cb19dab5c1de379447", size = 145451, upload-time = "2025-05-24T15:46:13.49Z" }, + { url = "https://files.pythonhosted.org/packages/53/d0/321b5301e36ac1577dbf73cb49769779c41ebf72ba70a3f6f62d34df902b/pyinstrument-5.0.2-cp312-cp312-win32.whl", hash = "sha256:29e565ce85e03d2541330a8174124c1ecdb073d945962a8eb738d3b1c806ac83", size = 123491, upload-time = "2025-05-24T15:46:15.319Z" }, + { url = "https://files.pythonhosted.org/packages/ae/a6/40f05febe6ab0856b4bfa119113d550d868d94a36b501e6b9fd64379b4ba/pyinstrument-5.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:300b0cc453ffe7661d5f3ceb94cdd98996fd9118f5ff1182b5336489c7d4e45c", size = 124277, upload-time = "2025-05-24T15:46:16.693Z" }, + { url = "https://files.pythonhosted.org/packages/03/88/48654e4b8c6853f218e0506e0609060a54559500b3af5ed6ac752ac4d64f/pyinstrument-5.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8141a5f78b927a88de46fb2bbb17e710e41d16e161fca99991635ff7196dbd5d", size = 129528, upload-time = "2025-05-24T15:46:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/92/a7/885418b733350f6c2b1d8fcca322a1eee87216a266ac516d7aefd6757ec8/pyinstrument-5.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:12a0095ae408dbbdd429501fd4c6a3ab51d1aeff5f31be36cc3eedc8c4870ede", size = 122072, upload-time = "2025-05-24T15:46:19.513Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d5/dd0b323d2949d1a3ee0531ec6cdd66c3c69c13b9a8739aeec929a0b55fd2/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eca651d840e8e75ae5330abfc5c90f6ea4af3f78f9f0269231328305a5f9c667", size = 146874, upload-time = "2025-05-24T15:46:21.38Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/429572b57c9ae2874e86c48db91ddcd5d619bd798f73d7d2e51b28abb08d/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:89d6ffc5459b19f1c85d4433bb9bbc8925ec04a8d7caf2694218b1f557555f23", size = 155257, upload-time = "2025-05-24T15:46:22.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/98/03cd22f68607362fd8d1ba72e6367104a9dc32bd4a0dbafc823c4e366f35/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c84845ccc5318072708dc5535b6bedd54494e92a68e282e6b97b53c1db65331", size = 144380, upload-time = "2025-05-24T15:46:24.26Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c4/40d7b4be6c9620c4d9bbe9788eb9bac892f386c9bd40f1937464b2b95c09/pyinstrument-5.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6511092384b5729bbbf4b35534120d2969c5fdfd4f39080badedd973676b8725", size = 145794, upload-time = "2025-05-24T15:46:25.751Z" }, + { url = "https://files.pythonhosted.org/packages/05/07/3b2084b78521d5bbbc328ca9527fb54fbf645a5e62f25169b49f7bbb0bc3/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73f08cff7a8d9714be15440046289ab1a70cbc429e09967a3a106ac61538773e", size = 145803, upload-time = "2025-05-24T15:46:27.277Z" }, + { url = "https://files.pythonhosted.org/packages/22/eb/e3ffcc8734e3d9f50b6bb750209c3ad0c4626dcc3754529741499d9f1d5c/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3905b510cdab1a8255a23fbdedcba4685245cbf814fd80f5b2005b472161d16e", size = 145763, upload-time = "2025-05-24T15:46:28.656Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/6b94945a02afced9e486e9a6b20de0edcfec543e4942dea96d745e2148ac/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cd693a616166679da529168037c294ff25746c7ae5e8b547811fb25bb26439f5", size = 145208, upload-time = "2025-05-24T15:46:30.125Z" }, + { url = "https://files.pythonhosted.org/packages/99/af/0339bbfe52de9a7df01e5a244a5fec4c228d23b1f422a55318fc6d0b9d91/pyinstrument-5.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:83a1659a3bc4123c81fcddfcc86608f37bd6a951da9692766c2251500a77ac06", size = 145591, upload-time = "2025-05-24T15:46:31.556Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f4/76a2c652e203c15cbc7aa3f8341e07d1ea865764b3ed9f9a97b3c4a5eda2/pyinstrument-5.0.2-cp313-cp313-win32.whl", hash = "sha256:386d047db6c043dcc86bac592873234a89eaa258460e1ad8f47a11fcc7b024d5", size = 123490, upload-time = "2025-05-24T15:46:32.951Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/14f5c6253e8c85c758485c7717f542346a0d4487818afc28721912a1574b/pyinstrument-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:971c974c061019fa6177a021882255e639399bc15bf71b0a17979830702ad8d3", size = 124287, upload-time = "2025-05-24T15:46:34.333Z" }, +] + [[package]] name = "pyperclip" version = "1.9.0" From ed94a2ae8977f29cef9193dc7fb59c6c17a2d1c0 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 11:57:44 -0400 Subject: [PATCH 22/34] Update test_bearer.py --- tests/auth/providers/test_bearer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index 8f81d4339..480c9a1b9 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -32,7 +32,7 @@ def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider: ) -def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> str: +def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> None: mcp = FastMCP( auth=BearerAuthProvider( issuer="https://test.example.com", @@ -65,7 +65,7 @@ class TestRSAKeyPair: # Check that keys are in PEM format private_pem = key_pair.private_key.get_secret_value() - public_pem = key_pair.public_key.get_secret_value() + public_pem = key_pair.public_key assert "-----BEGIN PRIVATE KEY-----" in private_pem assert "-----END PRIVATE KEY-----" in private_pem From da9c51e13289f406fb2220caa3d7d746b60d355a Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 12:09:30 -0400 Subject: [PATCH 23/34] Add tests; update default issuer --- src/fastmcp/server/auth/providers/bearer.py | 2 +- tests/auth/providers/test_bearer.py | 54 ++++++++++++++++++--- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index 3cedfee7b..ccf25f0bd 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -177,7 +177,7 @@ class BearerAuthProvider(OAuthProvider): raise ValueError("Provide either public_key or jwks_uri, not both") super().__init__( - issuer_url=issuer or "http://fastmcp.example.com", + issuer_url=issuer or "https://fastmcp.example.com", client_registration_options=ClientRegistrationOptions(enabled=False), revocation_options=RevocationOptions(enabled=False), required_scopes=required_scopes, diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index 480c9a1b9..ccc84fe73 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -1,4 +1,5 @@ from collections.abc import Generator +from typing import Any import httpx import pytest @@ -32,11 +33,17 @@ def bearer_provider(rsa_key_pair: RSAKeyPair) -> BearerAuthProvider: ) -def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> None: +def run_mcp_server( + public_key: str, + host: str, + port: int, + auth_kwargs: dict[str, Any] | None = None, + run_kwargs: dict[str, Any] | None = None, +) -> None: mcp = FastMCP( auth=BearerAuthProvider( - issuer="https://test.example.com", public_key=public_key, + **auth_kwargs or {}, ) ) @@ -44,13 +51,15 @@ def run_mcp_server(public_key: str, host: str, port: int, **kwargs) -> None: def add(a: int, b: int) -> int: return a + b - mcp.run(host=host, port=port, **kwargs) + mcp.run(host=host, port=port, **run_kwargs or {}) @pytest.fixture(scope="module") def mcp_server_url(rsa_key_pair: RSAKeyPair) -> Generator[str]: with run_server_in_process( - run_mcp_server, public_key=rsa_key_pair.public_key, transport="streamable-http" + run_mcp_server, + public_key=rsa_key_pair.public_key, + run_kwargs=dict(transport="streamable-http"), ) as url: yield f"{url}/mcp" @@ -75,7 +84,8 @@ class TestRSAKeyPair: def test_create_basic_token(self, rsa_key_pair: RSAKeyPair): """Test basic token creation.""" token = rsa_key_pair.create_token( - subject="test-user", issuer="https://test.example.com" + subject="test-user", + issuer="https://test.example.com", ) assert isinstance(token, str) @@ -359,8 +369,38 @@ class TestFastMCPBearerAuth: async def test_unauthorized_access(self, mcp_server_url: str): with pytest.raises(httpx.HTTPStatusError, match="401"): async with Client(mcp_server_url) as client: - await client.ping() + tools = await client.list_tools() # noqa: F841 + assert "tools" not in locals() async def test_authorized_access(self, mcp_server_url: str, bearer_token): async with Client(mcp_server_url, auth=BearerAuth(bearer_token)) as client: - await client.ping() + tools = await client.list_tools() # noqa: F841 + assert tools + + async def test_invalid_token_raises_401(self, mcp_server_url: str): + with pytest.raises(httpx.HTTPStatusError, match="401"): + async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client: + tools = await client.list_tools() # noqa: F841 + assert "tools" not in locals() + + async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair): + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + expires_in_seconds=-3600, + ) + + with pytest.raises(httpx.HTTPStatusError, match="401"): + async with Client(mcp_server_url, auth=BearerAuth(token)) as client: + tools = await client.list_tools() # noqa: F841 + assert "tools" not in locals() + + async def test_token_with_bad_signature(self, mcp_server_url: str): + rsa_key_pair = RSAKeyPair.generate() + token = rsa_key_pair.create_token() + + with pytest.raises(httpx.HTTPStatusError, match="401"): + async with Client(mcp_server_url, auth=BearerAuth(token)) as client: + tools = await client.list_tools() # noqa: F841 + assert "tools" not in locals() From 172f67c6176105cc895590a4c3144d5a560391bc Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 12:44:56 -0400 Subject: [PATCH 24/34] Add jwks tests --- pyproject.toml | 1 + src/fastmcp/server/auth/bearer.py | 256 ------------------- src/fastmcp/server/auth/providers/bearer.py | 75 ++++-- tests/auth/providers/test_bearer.py | 264 ++++++++++++++++++-- uv.lock | 15 ++ 5 files changed, 318 insertions(+), 293 deletions(-) delete mode 100644 src/fastmcp/server/auth/bearer.py diff --git a/pyproject.toml b/pyproject.toml index 35b538b58..a6fce4ad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "pytest-cov>=6.1.1", "pytest-env>=1.1.5", "pytest-flakefinder", + "pytest-httpx>=0.35.0", "pytest-report>=0.2.1", "pytest-timeout>=2.4.0", "pytest-xdist>=3.6.1", diff --git a/src/fastmcp/server/auth/bearer.py b/src/fastmcp/server/auth/bearer.py deleted file mode 100644 index 729fcf1dc..000000000 --- a/src/fastmcp/server/auth/bearer.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Simple JWT Bearer Token validation for hosted MCP servers. - -Uses RS256 (asymmetric) where your control plane signs with a private key -and hosted MCP servers validate with the corresponding public key. - -Example usage: -# Static public key -provider = BearerTokenValidatorProvider( - public_key='''-----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... - -----END PUBLIC KEY-----''', - issuer="https://auth.yourservice.com" -) - -# Or JWKS URI (recommended for production - allows key rotation) -provider = BearerTokenValidatorProvider( - jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", - issuer="https://auth.yourservice.com" -) -""" - -import time -from typing import Any - -import httpx -from authlib.jose import JsonWebKey, JsonWebToken -from authlib.jose.errors import JoseError -from mcp.server.auth.provider import ( - AccessToken, - AuthorizationCode, - AuthorizationParams, - RefreshToken, -) -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthToken, -) - -from fastmcp.server.auth.auth import ( - ClientRegistrationOptions, - OAuthProvider, - RevocationOptions, -) - - -class BearerTokenValidatorProvider(OAuthProvider): - """ - Simple JWT Bearer Token validator for hosted MCP servers. - Uses RS256 asymmetric encryption. Supports either static public key - or JWKS URI for key rotation. - """ - - def __init__( - self, - issuer: str, - public_key: str | None = None, - jwks_uri: str | None = None, - audience: str | None = None, - required_scopes: list[str] | None = None, - ): - """ - Initialize the provider. - - Args: - issuer: Expected issuer claim (your control plane) - public_key: RSA public key in PEM format (for static key) - jwks_uri: URI to fetch keys from (for key rotation) - audience: Expected audience claim (optional) - required_scopes: List of required scopes for access - """ - if not (public_key or jwks_uri): - raise ValueError("Either public_key or jwks_uri must be provided") - if public_key and jwks_uri: - raise ValueError("Provide either public_key or jwks_uri, not both") - - super().__init__( - issuer_url=issuer, - client_registration_options=ClientRegistrationOptions(enabled=False), - revocation_options=RevocationOptions(enabled=False), - required_scopes=required_scopes, - ) - - self.issuer = issuer - self.audience = audience - self.public_key = public_key - self.jwks_uri = jwks_uri - self.jwt = JsonWebToken(["RS256"]) - - # Simple JWKS cache - self._jwks_cache: dict[str, str] = {} - self._jwks_cache_time: float = 0 - self._cache_ttl = 3600 # 1 hour - - async def _get_verification_key(self, token: str) -> str: - """Get the verification key for the token.""" - if self.public_key: - return self.public_key - - # Extract kid from token header for JWKS lookup - try: - import base64 - import json - - header_b64 = token.split(".")[0] - header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding - header = json.loads(base64.urlsafe_b64decode(header_b64)) - kid = header.get("kid") - - if not kid: - raise ValueError("Token missing key ID (kid)") - - return await self._get_jwks_key(kid) - - except Exception as e: - raise ValueError(f"Failed to extract key ID from token: {e}") - - async def _get_jwks_key(self, kid: str) -> str: - """Fetch key from JWKS with simple caching.""" - if not self.jwks_uri: - raise ValueError("JWKS URI not configured") - - current_time = time.time() - - # Check cache - if ( - current_time - self._jwks_cache_time < self._cache_ttl - and kid in self._jwks_cache - ): - return self._jwks_cache[kid] - - # Fetch JWKS - try: - async with httpx.AsyncClient() as client: - response = await client.get(self.jwks_uri) - response.raise_for_status() - jwks_data = response.json() - - # Cache all keys - self._jwks_cache = {} - for key_data in jwks_data.get("keys", []): - key_kid = key_data.get("kid") - if key_kid: - jwk = JsonWebKey.import_key(key_data) - self._jwks_cache[key_kid] = jwk.get_public_key() - - self._jwks_cache_time = current_time - - if kid not in self._jwks_cache: - raise ValueError(f"Key ID '{kid}' not found in JWKS") - - return self._jwks_cache[kid] - - except Exception as e: - raise ValueError(f"Failed to fetch JWKS: {e}") - - async def load_access_token(self, token: str) -> AccessToken | None: - """ - Validates the provided JWT bearer token. - - Args: - token: The JWT token string to validate - - Returns: - AccessToken object if valid, None if invalid or expired - """ - try: - # Get verification key (static or from JWKS) - verification_key = await self._get_verification_key(token) - - # Decode and verify the JWT token - claims = self.jwt.decode(token, verification_key) - - # Validate expiration - exp = claims.get("exp") - if exp and exp < time.time(): - return None - - # Validate issuer - if claims.get("iss") != self.issuer: - return None - - # Validate audience if configured - if self.audience: - aud = claims.get("aud") - if isinstance(aud, list): - if self.audience not in aud: - return None - elif aud != self.audience: - return None - - # Extract claims - client_id = claims.get("sub") or claims.get("client_id") or "unknown" - scopes = self._extract_scopes(claims) - - return AccessToken( - token=token, - client_id=str(client_id), - scopes=scopes, - expires_at=int(exp) if exp else None, - ) - - except JoseError: - return None - except Exception: - return None - - def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: - """Extract scopes from JWT claims.""" - scope_claim = claims.get("scope", "") - if isinstance(scope_claim, str): - return scope_claim.split() - elif isinstance(scope_claim, list): - return scope_claim - return [] - - # --- Unused OAuth server methods --- - async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - raise NotImplementedError("Client management not supported") - - async def register_client(self, client_info: OAuthClientInformationFull) -> None: - raise NotImplementedError("Client registration not supported") - - async def authorize( - self, client: OAuthClientInformationFull, params: AuthorizationParams - ) -> str: - raise NotImplementedError("Authorization flow not supported") - - async def load_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: str - ) -> AuthorizationCode | None: - raise NotImplementedError("Authorization code flow not supported") - - async def exchange_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode - ) -> OAuthToken: - raise NotImplementedError("Authorization code exchange not supported") - - async def load_refresh_token( - self, client: OAuthClientInformationFull, refresh_token: str - ) -> RefreshToken | None: - raise NotImplementedError("Refresh token flow not supported") - - async def exchange_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: RefreshToken, - scopes: list[str], - ) -> OAuthToken: - raise NotImplementedError("Refresh token exchange not supported") - - async def revoke_token( - self, - token: AccessToken | RefreshToken, - ) -> None: - raise NotImplementedError("Token revocation not supported") diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index ccf25f0bd..419ed255c 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -6,7 +6,7 @@ and hosted MCP servers validate with the corresponding public key. Example usage: # Static public key -provider = BearerTokenValidatorProvider( +provider = BearerAuthProvider( public_key='''-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... -----END PUBLIC KEY-----''', @@ -14,7 +14,7 @@ provider = BearerTokenValidatorProvider( ) # Or JWKS URI (recommended for production - allows key rotation) -provider = BearerTokenValidatorProvider( +provider = Bear( jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", issuer="https://auth.yourservice.com" ) @@ -22,7 +22,7 @@ provider = BearerTokenValidatorProvider( import time from dataclasses import dataclass -from typing import Any +from typing import Any, TypedDict import httpx from authlib.jose import JsonWebKey, JsonWebToken @@ -48,6 +48,25 @@ from fastmcp.server.auth.auth import ( ) +class JWKData(TypedDict, total=False): + """JSON Web Key data structure.""" + + kty: str # Key type (e.g., "RSA") - required + kid: str # Key ID (optional but recommended) + use: str # Usage (e.g., "sig") + alg: str # Algorithm (e.g., "RS256") + n: str # Modulus (for RSA keys) + e: str # Exponent (for RSA keys) + x5c: list[str] # X.509 certificate chain (for JWKs) + x5t: str # X.509 certificate thumbprint (for JWKs) + + +class JWKSData(TypedDict): + """JSON Web Key Set data structure.""" + + keys: list[JWKData] + + @dataclass(frozen=True, kw_only=True, repr=False) class RSAKeyPair: private_key: SecretStr @@ -96,6 +115,7 @@ class RSAKeyPair: scopes: list[str] | None = None, expires_in_seconds: int = 3600, additional_claims: dict[str, Any] | None = None, + kid: str | None = None, ) -> str: """ Generate a test JWT token for testing purposes. @@ -108,6 +128,7 @@ class RSAKeyPair: scopes: List of scopes to include expires_in_seconds: Token expiration time in seconds additional_claims: Any additional claims to include + kid: Key ID for JWKS lookup (optional) Returns: Signed JWT token string @@ -135,6 +156,8 @@ class RSAKeyPair: # Create header header = {"alg": "RS256"} + if kid: + header["kid"] = kid # Sign and return token token_bytes = jwt.encode( @@ -209,27 +232,25 @@ class BearerAuthProvider(OAuthProvider): header = json.loads(base64.urlsafe_b64decode(header_b64)) kid = header.get("kid") - if not kid: - raise ValueError("Token missing key ID (kid)") - return await self._get_jwks_key(kid) except Exception as e: raise ValueError(f"Failed to extract key ID from token: {e}") - async def _get_jwks_key(self, kid: str) -> str: + async def _get_jwks_key(self, kid: str | None) -> str: """Fetch key from JWKS with simple caching.""" if not self.jwks_uri: raise ValueError("JWKS URI not configured") current_time = time.time() - # Check cache - if ( - current_time - self._jwks_cache_time < self._cache_ttl - and kid in self._jwks_cache - ): - return self._jwks_cache[kid] + # Check cache first + if current_time - self._jwks_cache_time < self._cache_ttl: + if kid and kid in self._jwks_cache: + return self._jwks_cache[kid] + elif not kid and len(self._jwks_cache) == 1: + # If no kid but only one key cached, use it + return next(iter(self._jwks_cache.values())) # Fetch JWKS try: @@ -242,16 +263,32 @@ class BearerAuthProvider(OAuthProvider): self._jwks_cache = {} for key_data in jwks_data.get("keys", []): key_kid = key_data.get("kid") + jwk = JsonWebKey.import_key(key_data) + public_key = jwk.get_public_key() + if key_kid: - jwk = JsonWebKey.import_key(key_data) - self._jwks_cache[key_kid] = jwk.get_public_key() + self._jwks_cache[key_kid] = public_key + else: + # Key without kid - use a default identifier + self._jwks_cache["_default"] = public_key self._jwks_cache_time = current_time - if kid not in self._jwks_cache: - raise ValueError(f"Key ID '{kid}' not found in JWKS") - - return self._jwks_cache[kid] + # Select the appropriate key + if kid: + if kid not in self._jwks_cache: + raise ValueError(f"Key ID '{kid}' not found in JWKS") + return self._jwks_cache[kid] + else: + # No kid in token - only allow if there's exactly one key + if len(self._jwks_cache) == 1: + return next(iter(self._jwks_cache.values())) + elif len(self._jwks_cache) > 1: + raise ValueError( + "Multiple keys in JWKS but no key ID (kid) in token" + ) + else: + raise ValueError("No keys found in JWKS") except Exception as e: raise ValueError(f"Failed to fetch JWKS: {e}") diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index ccc84fe73..a54f4d416 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -3,10 +3,15 @@ from typing import Any import httpx import pytest +from pytest_httpx import HTTPXMock from fastmcp import Client, FastMCP from fastmcp.client.auth import BearerAuth -from fastmcp.server.auth.providers.bearer import BearerAuthProvider, RSAKeyPair +from fastmcp.server.auth.providers.bearer import ( + BearerAuthProvider, + JWKSData, + RSAKeyPair, +) from fastmcp.utilities.tests import run_server_in_process @@ -103,6 +108,194 @@ class TestRSAKeyPair: # We'll validate the scopes in the BearerToken tests +class TestBearerTokenJWKS: + """Tests for JWKS URI functionality.""" + + @pytest.fixture + def jwks_provider(self, rsa_key_pair: RSAKeyPair) -> BearerAuthProvider: + """Provider configured with JWKS URI.""" + return BearerAuthProvider( + jwks_uri="https://test.example.com/.well-known/jwks.json", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + @pytest.fixture + def mock_jwks_data(self, rsa_key_pair: RSAKeyPair) -> JWKSData: + """Create mock JWKS data from RSA key pair.""" + from authlib.jose import JsonWebKey + + # Create JWK from the RSA public key + jwk = JsonWebKey.import_key(rsa_key_pair.public_key) + jwk_data = jwk.as_dict() + jwk_data["kid"] = "test-key-1" + jwk_data["alg"] = "RS256" + + return {"keys": [jwk_data]} + + async def test_jwks_token_validation( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: BearerAuthProvider, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + ): + """Test token validation using JWKS URI.""" + httpx_mock.add_response( + url="https://test.example.com/.well-known/jwks.json", + json=mock_jwks_data, + ) + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await jwks_provider.load_access_token(token) + assert access_token is not None + assert access_token.client_id == "test-user" + + async def test_jwks_token_validation_with_invalid_key( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: BearerAuthProvider, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + ): + httpx_mock.add_response( + url="https://test.example.com/.well-known/jwks.json", + json=mock_jwks_data, + ) + token = RSAKeyPair.generate().create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await jwks_provider.load_access_token(token) + assert access_token is None + + async def test_jwks_token_validation_with_kid( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: BearerAuthProvider, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + ): + mock_jwks_data["keys"][0]["kid"] = "test-key-1" + httpx_mock.add_response( + url="https://test.example.com/.well-known/jwks.json", + json=mock_jwks_data, + ) + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + kid="test-key-1", + ) + + access_token = await jwks_provider.load_access_token(token) + assert access_token is not None + assert access_token.client_id == "test-user" + + async def test_jwks_token_validation_with_kid_and_no_kid_in_token( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: BearerAuthProvider, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + ): + mock_jwks_data["keys"][0]["kid"] = "test-key-1" + httpx_mock.add_response( + url="https://test.example.com/.well-known/jwks.json", + json=mock_jwks_data, + ) + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await jwks_provider.load_access_token(token) + assert access_token is not None + assert access_token.client_id == "test-user" + + async def test_jwks_token_validation_with_no_kid_and_kid_in_jwks( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: BearerAuthProvider, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + ): + mock_jwks_data["keys"][0]["kid"] = "test-key-1" + httpx_mock.add_response( + url="https://test.example.com/.well-known/jwks.json", + json=mock_jwks_data, + ) + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await jwks_provider.load_access_token(token) + assert access_token is not None + assert access_token.client_id == "test-user" + + async def test_jwks_token_validation_with_kid_mismatch( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: BearerAuthProvider, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + ): + mock_jwks_data["keys"][0]["kid"] = "test-key-1" + httpx_mock.add_response( + url="https://test.example.com/.well-known/jwks.json", + json=mock_jwks_data, + ) + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + kid="test-key-2", + ) + + access_token = await jwks_provider.load_access_token(token) + assert access_token is None + + async def test_jwks_token_validation_with_multiple_keys_and_no_kid_in_token( + self, + rsa_key_pair: RSAKeyPair, + jwks_provider: BearerAuthProvider, + mock_jwks_data: JWKSData, + httpx_mock: HTTPXMock, + ): + mock_jwks_data["keys"] = [ + { + "kid": "test-key-1", + "alg": "RS256", + }, + { + "kid": "test-key-2", + "alg": "RS256", + }, + ] + + httpx_mock.add_response( + url="https://test.example.com/.well-known/jwks.json", + json=mock_jwks_data, + ) + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + ) + + access_token = await jwks_provider.load_access_token(token) + assert access_token is None + + class TestBearerToken: def test_initialization_with_public_key(self, rsa_key_pair: RSAKeyPair): """Test provider initialization with public key.""" @@ -143,7 +336,6 @@ class TestBearerToken: issuer="https://test.example.com", ) - @pytest.mark.asyncio async def test_valid_token_validation( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -163,7 +355,6 @@ class TestBearerToken: assert "write" in access_token.scopes assert access_token.expires_at is not None - @pytest.mark.asyncio async def test_expired_token_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -178,7 +369,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_invalid_issuer_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -192,7 +382,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_invalid_audience_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -206,7 +395,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_no_issuer_validation_when_none(self, rsa_key_pair: RSAKeyPair): """Test that issuer validation is skipped when provider has no issuer configured.""" provider = BearerAuthProvider( @@ -221,7 +409,6 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - @pytest.mark.asyncio async def test_no_audience_validation_when_none(self, rsa_key_pair: RSAKeyPair): """Test that audience validation is skipped when provider has no audience configured.""" provider = BearerAuthProvider( @@ -239,7 +426,6 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - @pytest.mark.asyncio async def test_multiple_audiences_validation(self, rsa_key_pair: RSAKeyPair): """Test validation with multiple audiences in token.""" provider = BearerAuthProvider( @@ -259,7 +445,6 @@ class TestBearerToken: access_token = await provider.load_access_token(token) assert access_token is not None - @pytest.mark.asyncio async def test_scope_extraction_string( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -276,7 +461,6 @@ class TestBearerToken: assert access_token is not None assert set(access_token.scopes) == {"read", "write", "admin"} - @pytest.mark.asyncio async def test_scope_extraction_list( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -293,7 +477,6 @@ class TestBearerToken: assert access_token is not None assert set(access_token.scopes) == {"read", "write"} - @pytest.mark.asyncio async def test_no_scopes( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -310,7 +493,6 @@ class TestBearerToken: assert access_token is not None assert access_token.scopes == [] - @pytest.mark.asyncio async def test_malformed_token_rejection(self, bearer_provider: BearerAuthProvider): """Test rejection of malformed tokens.""" malformed_tokens = [ @@ -325,7 +507,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_invalid_signature_rejection( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -341,7 +522,6 @@ class TestBearerToken: access_token = await bearer_provider.load_access_token(token) assert access_token is None - @pytest.mark.asyncio async def test_client_id_fallback( self, rsa_key_pair: RSAKeyPair, bearer_provider: BearerAuthProvider ): @@ -367,9 +547,10 @@ class TestFastMCPBearerAuth: assert isinstance(mcp.auth, BearerAuthProvider) async def test_unauthorized_access(self, mcp_server_url: str): - with pytest.raises(httpx.HTTPStatusError, match="401"): + with pytest.raises(httpx.HTTPStatusError) as exc_info: async with Client(mcp_server_url) as client: tools = await client.list_tools() # noqa: F841 + assert exc_info.value.response.status_code == 401 assert "tools" not in locals() async def test_authorized_access(self, mcp_server_url: str, bearer_token): @@ -378,9 +559,10 @@ class TestFastMCPBearerAuth: assert tools async def test_invalid_token_raises_401(self, mcp_server_url: str): - with pytest.raises(httpx.HTTPStatusError, match="401"): + with pytest.raises(httpx.HTTPStatusError) as exc_info: async with Client(mcp_server_url, auth=BearerAuth("invalid")) as client: tools = await client.list_tools() # noqa: F841 + assert exc_info.value.response.status_code == 401 assert "tools" not in locals() async def test_expired_token(self, mcp_server_url: str, rsa_key_pair: RSAKeyPair): @@ -391,16 +573,62 @@ class TestFastMCPBearerAuth: expires_in_seconds=-3600, ) - with pytest.raises(httpx.HTTPStatusError, match="401"): + 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 + assert exc_info.value.response.status_code == 401 assert "tools" not in locals() async def test_token_with_bad_signature(self, mcp_server_url: str): rsa_key_pair = RSAKeyPair.generate() token = rsa_key_pair.create_token() - with pytest.raises(httpx.HTTPStatusError, match="401"): + 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 + 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 + ): + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + scopes=["read"], + ) + + with run_server_in_process( + run_mcp_server, + public_key=rsa_key_pair.public_key, + auth_kwargs=dict(required_scopes=["read", "write"]), + run_kwargs=dict(transport="streamable-http"), + ) as url: + mcp_server_url = f"{url}/mcp" + 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 + assert exc_info.value.response.status_code == 403 + assert "tools" not in locals() + + async def test_token_with_sufficient_scopes( + self, mcp_server_url: str, rsa_key_pair: RSAKeyPair + ): + token = rsa_key_pair.create_token( + subject="test-user", + issuer="https://test.example.com", + audience="https://api.example.com", + scopes=["read", "write"], + ) + + with run_server_in_process( + run_mcp_server, + public_key=rsa_key_pair.public_key, + auth_kwargs=dict(required_scopes=["read", "write"]), + run_kwargs=dict(transport="streamable-http"), + ) as url: + mcp_server_url = f"{url}/mcp" + async with Client(mcp_server_url, auth=BearerAuth(token)) as client: + tools = await client.list_tools() + assert tools diff --git a/uv.lock b/uv.lock index e1cd22726..5097b2bcc 100644 --- a/uv.lock +++ b/uv.lock @@ -456,6 +456,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-env" }, { name = "pytest-flakefinder" }, + { name = "pytest-httpx" }, { name = "pytest-report" }, { name = "pytest-timeout" }, { name = "pytest-xdist" }, @@ -490,6 +491,7 @@ dev = [ { name = "pytest-cov", specifier = ">=6.1.1" }, { name = "pytest-env", specifier = ">=1.1.5" }, { name = "pytest-flakefinder" }, + { name = "pytest-httpx", specifier = ">=0.35.0" }, { name = "pytest-report", specifier = ">=0.2.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.6.1" }, @@ -1160,6 +1162,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/8b/06787150d0fd0cbd3a8054262b56f91631c7778c1bc91bf4637e47f909ad/pytest_flakefinder-1.1.0-py2.py3-none-any.whl", hash = "sha256:741e0e8eea427052f5b8c89c2b3c3019a50c39a59ce4df6a305a2c2d9ba2bd13", size = 4644, upload-time = "2022-10-26T18:27:52.128Z" }, ] +[[package]] +name = "pytest-httpx" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/89/5b12b7b29e3d0af3a4b9c071ee92fa25a9017453731a38f08ba01c280f4c/pytest_httpx-0.35.0.tar.gz", hash = "sha256:d619ad5d2e67734abfbb224c3d9025d64795d4b8711116b1a13f72a251ae511f", size = 54146, upload-time = "2024-11-28T19:16:54.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/ed/026d467c1853dd83102411a78126b4842618e86c895f93528b0528c7a620/pytest_httpx-0.35.0-py3-none-any.whl", hash = "sha256:ee11a00ffcea94a5cbff47af2114d34c5b231c326902458deed73f9c459fd744", size = 19442, upload-time = "2024-11-28T19:16:52.787Z" }, +] + [[package]] name = "pytest-report" version = "0.2.1" From c6d168ac2072fd9024ad00777b827dd045a54590 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 12:49:33 -0400 Subject: [PATCH 25/34] update typing --- src/fastmcp/server/auth/providers/bearer.py | 2 +- tests/auth/providers/test_bearer.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index 419ed255c..9c7c34512 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -264,7 +264,7 @@ class BearerAuthProvider(OAuthProvider): for key_data in jwks_data.get("keys", []): key_kid = key_data.get("kid") jwk = JsonWebKey.import_key(key_data) - public_key = jwk.get_public_key() + public_key = jwk.get_public_key() # type: ignore if key_kid: self._jwks_cache[key_kid] = public_key diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index a54f4d416..ff127f727 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -9,6 +9,7 @@ from fastmcp import Client, FastMCP from fastmcp.client.auth import BearerAuth from fastmcp.server.auth.providers.bearer import ( BearerAuthProvider, + JWKData, JWKSData, RSAKeyPair, ) @@ -126,8 +127,8 @@ class TestBearerTokenJWKS: from authlib.jose import JsonWebKey # Create JWK from the RSA public key - jwk = JsonWebKey.import_key(rsa_key_pair.public_key) - jwk_data = jwk.as_dict() + jwk = JsonWebKey.import_key(rsa_key_pair.public_key) # type: ignore + jwk_data: JWKData = jwk.as_dict() # type: ignore jwk_data["kid"] = "test-key-1" jwk_data["alg"] = "RS256" From db18baa24fa20e49b223a6735d31e9bab10c8354 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:24:45 -0400 Subject: [PATCH 26/34] Support configuring bearer auth from env vars --- src/fastmcp/server/auth/auth.py | 2 +- src/fastmcp/server/auth/providers/bearer.py | 34 ++------ .../server/auth/providers/bearer_env.py | 54 ++++++++++++ src/fastmcp/server/http.py | 10 +-- src/fastmcp/server/server.py | 3 + src/fastmcp/settings.py | 8 +- tests/auth/providers/test_bearer_env.py | 83 +++++++++++++++++++ 7 files changed, 160 insertions(+), 34 deletions(-) create mode 100644 src/fastmcp/server/auth/providers/bearer_env.py create mode 100644 tests/auth/providers/test_bearer_env.py diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index b5f07c523..d4ae8c821 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -39,7 +39,7 @@ class OAuthProvider( if isinstance(service_documentation_url, str): service_documentation_url = AnyHttpUrl(service_documentation_url) - self.settings = AuthSettings( + self.auth_settings = AuthSettings( issuer_url=issuer_url, service_documentation_url=service_documentation_url, client_registration_options=client_registration_options, diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index 9c7c34512..a463817e6 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -1,25 +1,3 @@ -""" -Simple JWT Bearer Token validation for hosted MCP servers. - -Uses RS256 (asymmetric) where your control plane signs with a private key -and hosted MCP servers validate with the corresponding public key. - -Example usage: -# Static public key -provider = BearerAuthProvider( - public_key='''-----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... - -----END PUBLIC KEY-----''', - issuer="https://auth.yourservice.com" -) - -# Or JWKS URI (recommended for production - allows key rotation) -provider = Bear( - jwks_uri="https://auth.yourservice.com/.well-known/jwks.json", - issuer="https://auth.yourservice.com" -) -""" - import time from dataclasses import dataclass from typing import Any, TypedDict @@ -165,7 +143,6 @@ class RSAKeyPair: payload, key=self.private_key.get_secret_value(), ) - return token_bytes.decode("utf-8") @@ -174,25 +151,28 @@ class BearerAuthProvider(OAuthProvider): Simple JWT Bearer Token validator for hosted MCP servers. Uses RS256 asymmetric encryption. Supports either static public key or JWKS URI for key rotation. + + Note that this provider DOES NOT permit client registration or revocation, or any OAuth flows. + It is intended to be used with a control plane that manages clients and tokens. """ def __init__( self, - issuer: str | None = None, public_key: str | None = None, jwks_uri: str | None = None, + issuer: str | None = None, audience: str | None = None, required_scopes: list[str] | None = None, ): """ - Initialize the provider. + Initialize the provider. Either public_key or jwks_uri must be provided. Args: - issuer: Expected issuer claim (your control plane) public_key: RSA public key in PEM format (for static key) jwks_uri: URI to fetch keys from (for key rotation) + issuer: Expected issuer claim (optional) audience: Expected audience claim (optional) - required_scopes: List of required scopes for access + required_scopes: List of required scopes for access (optional) """ if not (public_key or jwks_uri): raise ValueError("Either public_key or jwks_uri must be provided") diff --git a/src/fastmcp/server/auth/providers/bearer_env.py b/src/fastmcp/server/auth/providers/bearer_env.py new file mode 100644 index 000000000..63eb8c725 --- /dev/null +++ b/src/fastmcp/server/auth/providers/bearer_env.py @@ -0,0 +1,54 @@ +from enum import Enum + +from pydantic_settings import BaseSettings, SettingsConfigDict + +from fastmcp.server.auth.providers.bearer import BearerAuthProvider + + +class NotSet(Enum): + sentinel = 0 + + +NOTSET = NotSet.sentinel + + +class EnvBearerAuthProviderSettings(BaseSettings): + """Settings for the BearerAuthProvider.""" + + model_config = SettingsConfigDict( + env_prefix="FASTMCP_AUTH_BEARER_", + env_file=".env", + extra="ignore", + ) + + public_key: str | None = None + jwks_uri: str | None = None + issuer: str | None = None + audience: str | None = None + required_scopes: list[str] | None = None + + +class EnvBearerAuthProvider(BearerAuthProvider): + """ + A BearerAuthProvider that loads settings from environment variables. + """ + + def __init__( + self, + public_key: str | None | NotSet = NOTSET, + jwks_uri: str | None | NotSet = NOTSET, + issuer: str | None | NotSet = NOTSET, + audience: str | None | NotSet = NOTSET, + required_scopes: list[str] | None | NotSet = NOTSET, + ): + kwargs = { + "public_key": public_key, + "jwks_uri": jwks_uri, + "issuer": issuer, + "audience": audience, + "required_scopes": required_scopes, + } + settings = EnvBearerAuthProviderSettings( + **{k: v for k, v in kwargs.items() if v is not NOTSET} + ) + super().__init__(**settings.model_dump()) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index d0501431f..d9530adb1 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -91,15 +91,15 @@ def setup_auth_middleware_and_routes( Middleware(AuthContextMiddleware), ] - required_scopes = auth.settings.required_scopes or [] + required_scopes = auth.auth_settings.required_scopes or [] auth_routes.extend( create_auth_routes( provider=auth, - issuer_url=auth.settings.issuer_url, - service_documentation_url=auth.settings.service_documentation_url, - client_registration_options=auth.settings.client_registration_options, - revocation_options=auth.settings.revocation_options, + issuer_url=auth.auth_settings.issuer_url, + service_documentation_url=auth.auth_settings.service_documentation_url, + client_registration_options=auth.auth_settings.client_registration_options, + revocation_options=auth.auth_settings.revocation_options, ) ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 5af2b596d..fe95ec6e1 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -48,6 +48,7 @@ from fastmcp.prompts.prompt import PromptResult from fastmcp.resources import Resource, ResourceManager from fastmcp.resources.template import ResourceTemplate from fastmcp.server.auth.auth import OAuthProvider +from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider from fastmcp.server.http import ( StarletteWithLifespan, create_sse_app, @@ -186,6 +187,8 @@ class FastMCP(Generic[LifespanResultT]): lifespan=_lifespan_wrapper(self, lifespan), ) + if auth is None and self.settings.auth_provider == "bearer_env": + auth = EnvBearerAuthProvider() self.auth = auth if tools: diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 405dc7ce8..6a2075b91 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -5,7 +5,10 @@ from pathlib import Path from typing import Annotated, Literal from pydantic import Field, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import ( + BaseSettings, + SettingsConfigDict, +) from typing_extensions import Self LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] @@ -176,5 +179,8 @@ class ServerSettings(BaseSettings): False # If True, uses true stateless mode (new transport per request) ) + # Auth settings + auth_provider: Literal["bearer_env"] | None = None + settings = Settings() diff --git a/tests/auth/providers/test_bearer_env.py b/tests/auth/providers/test_bearer_env.py new file mode 100644 index 000000000..7c81ba737 --- /dev/null +++ b/tests/auth/providers/test_bearer_env.py @@ -0,0 +1,83 @@ +import pytest +from pydantic import AnyHttpUrl, ValidationError + +from fastmcp import FastMCP +from fastmcp.server.auth.providers.bearer import BearerAuthProvider +from fastmcp.server.auth.providers.bearer_env import EnvBearerAuthProvider + + +def test_load_bearer_env_from_env_var(monkeypatch): + mcp = FastMCP() + assert mcp.auth is None + + monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") + + mcp_with_auth = FastMCP() + assert isinstance(mcp_with_auth.auth, EnvBearerAuthProvider) + + +def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatch): + mcp = FastMCP() + assert mcp.auth is None + + monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + + with pytest.raises( + ValueError, match="Either public_key or jwks_uri must be provided" + ): + FastMCP() + + +def test_configure_bearer_env_from_env_var(monkeypatch): + monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_ISSUER", "http://test-issuer") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_AUDIENCE", "test-audience") + monkeypatch.setenv( + "FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", '["test-scope1", "test-scope2"]' + ) + + mcp = FastMCP() + assert isinstance(mcp.auth, EnvBearerAuthProvider) + assert mcp.auth.public_key == "test-public-key" + assert mcp.auth.issuer == "http://test-issuer" + assert mcp.auth.auth_settings.issuer_url == AnyHttpUrl("http://test-issuer") + assert mcp.auth.audience == "test-audience" + assert mcp.auth.auth_settings.required_scopes == ["test-scope1", "test-scope2"] + + +def test_list_of_scopes_must_be_a_list(monkeypatch): + monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1") + + with pytest.raises(ValidationError, match="Input should be a valid list"): + FastMCP() + + +def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch): + monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri") + + mcp = FastMCP() + assert isinstance(mcp.auth, EnvBearerAuthProvider) + assert mcp.auth.jwks_uri == "test-jwks-uri" + + +def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch): + monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri") + + with pytest.raises(ValueError, match="Provide either public_key or jwks_uri"): + FastMCP() + + +def test_provided_auth_takes_precedence_over_env_vars(monkeypatch): + monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") + + mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2")) + assert isinstance(mcp.auth, BearerAuthProvider) + assert not isinstance(mcp.auth, EnvBearerAuthProvider) + assert mcp.auth.public_key == "test-public-key-2" From 0824b3783d5edf697a4f747b4bb67d5eca6921ec Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:37:50 -0400 Subject: [PATCH 27/34] Clean up settings --- .../server/auth/providers/bearer_env.py | 21 +++++++------------ src/fastmcp/server/server.py | 2 +- src/fastmcp/settings.py | 18 +++++++++++++++- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/fastmcp/server/auth/providers/bearer_env.py b/src/fastmcp/server/auth/providers/bearer_env.py index 63eb8c725..ff02af62f 100644 --- a/src/fastmcp/server/auth/providers/bearer_env.py +++ b/src/fastmcp/server/auth/providers/bearer_env.py @@ -1,15 +1,10 @@ -from enum import Enum - from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.providers.bearer import BearerAuthProvider -class NotSet(Enum): - sentinel = 0 - - -NOTSET = NotSet.sentinel +class NotSet: + pass class EnvBearerAuthProviderSettings(BaseSettings): @@ -35,11 +30,11 @@ class EnvBearerAuthProvider(BearerAuthProvider): def __init__( self, - public_key: str | None | NotSet = NOTSET, - jwks_uri: str | None | NotSet = NOTSET, - issuer: str | None | NotSet = NOTSET, - audience: str | None | NotSet = NOTSET, - required_scopes: list[str] | None | NotSet = NOTSET, + public_key: str | None | type[NotSet] = NotSet, + jwks_uri: str | None | type[NotSet] = NotSet, + issuer: str | None | type[NotSet] = NotSet, + audience: str | None | type[NotSet] = NotSet, + required_scopes: list[str] | None | type[NotSet] = NotSet, ): kwargs = { "public_key": public_key, @@ -49,6 +44,6 @@ class EnvBearerAuthProvider(BearerAuthProvider): "required_scopes": required_scopes, } settings = EnvBearerAuthProviderSettings( - **{k: v for k, v in kwargs.items() if v is not NOTSET} + **{k: v for k, v in kwargs.items() if v is not NotSet} ) super().__init__(**settings.model_dump()) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index fe95ec6e1..a092ed3b8 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -187,7 +187,7 @@ class FastMCP(Generic[LifespanResultT]): lifespan=_lifespan_wrapper(self, lifespan), ) - if auth is None and self.settings.auth_provider == "bearer_env": + if auth is None and self.settings.default_auth_provider == "bearer_env": auth = EnvBearerAuthProvider() self.auth = auth diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py index 6a2075b91..96939d11c 100644 --- a/src/fastmcp/settings.py +++ b/src/fastmcp/settings.py @@ -180,7 +180,23 @@ class ServerSettings(BaseSettings): ) # Auth settings - auth_provider: Literal["bearer_env"] | None = None + default_auth_provider: Annotated[ + Literal["bearer_env"] | None, + Field( + description=inspect.cleandoc( + """ + Configure the authentication provider. This setting is intended only to + be used for remote confirugation of providers that fully support + environment variable configuration. + + If None, no automatic configuration will take place. + + This setting is *always* overriden by any auth provider passed to the + FastMCP constructor. + """ + ), + ), + ] = None settings = Settings() From 0d8bb8cdd638d2fef3e360d974224a7b38f43659 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 16:45:31 -0400 Subject: [PATCH 28/34] flatten auth settings --- src/fastmcp/server/auth/auth.py | 13 ++++----- src/fastmcp/server/auth/providers/bearer.py | 3 +- .../server/auth/providers/bearer_env.py | 29 ++++++++++++++----- src/fastmcp/server/http.py | 10 +++---- tests/auth/providers/test_bearer_env.py | 19 ++++++------ 5 files changed, 42 insertions(+), 32 deletions(-) diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py index d4ae8c821..42d2919b8 100644 --- a/src/fastmcp/server/auth/auth.py +++ b/src/fastmcp/server/auth/auth.py @@ -5,7 +5,6 @@ from mcp.server.auth.provider import ( RefreshToken, ) from mcp.server.auth.settings import ( - AuthSettings, ClientRegistrationOptions, RevocationOptions, ) @@ -39,10 +38,8 @@ class OAuthProvider( if isinstance(service_documentation_url, str): service_documentation_url = AnyHttpUrl(service_documentation_url) - self.auth_settings = AuthSettings( - issuer_url=issuer_url, - service_documentation_url=service_documentation_url, - client_registration_options=client_registration_options, - revocation_options=revocation_options, - required_scopes=required_scopes, - ) + self.issuer_url = issuer_url + self.service_documentation_url = service_documentation_url + self.client_registration_options = client_registration_options + self.revocation_options = revocation_options + self.required_scopes = required_scopes diff --git a/src/fastmcp/server/auth/providers/bearer.py b/src/fastmcp/server/auth/providers/bearer.py index a463817e6..763f90f4f 100644 --- a/src/fastmcp/server/auth/providers/bearer.py +++ b/src/fastmcp/server/auth/providers/bearer.py @@ -295,7 +295,8 @@ class BearerAuthProvider(OAuthProvider): if exp and exp < time.time(): return None - # Validate issuer + # Validate issuer - note we use issuer instead of issuer_url here because + # issuer is optional, allowing users to make this check optional if self.issuer: if claims.get("iss") != self.issuer: return None diff --git a/src/fastmcp/server/auth/providers/bearer_env.py b/src/fastmcp/server/auth/providers/bearer_env.py index ff02af62f..96cf15cfa 100644 --- a/src/fastmcp/server/auth/providers/bearer_env.py +++ b/src/fastmcp/server/auth/providers/bearer_env.py @@ -3,7 +3,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from fastmcp.server.auth.providers.bearer import BearerAuthProvider -class NotSet: +# Sentinel object to indicate that a setting is not set +class _NotSet: pass @@ -25,17 +26,29 @@ class EnvBearerAuthProviderSettings(BaseSettings): class EnvBearerAuthProvider(BearerAuthProvider): """ - A BearerAuthProvider that loads settings from environment variables. + A BearerAuthProvider that loads settings from environment variables. Any + providing setting will always take precedence over the environment + variables. """ def __init__( self, - public_key: str | None | type[NotSet] = NotSet, - jwks_uri: str | None | type[NotSet] = NotSet, - issuer: str | None | type[NotSet] = NotSet, - audience: str | None | type[NotSet] = NotSet, - required_scopes: list[str] | None | type[NotSet] = NotSet, + public_key: str | None | type[_NotSet] = _NotSet, + jwks_uri: str | None | type[_NotSet] = _NotSet, + issuer: str | None | type[_NotSet] = _NotSet, + audience: str | None | type[_NotSet] = _NotSet, + required_scopes: list[str] | None | type[_NotSet] = _NotSet, ): + """ + Initialize the provider. + + Args: + public_key: RSA public key in PEM format (for static key) + jwks_uri: URI to fetch keys from (for key rotation) + issuer: Expected issuer claim (optional) + audience: Expected audience claim (optional) + required_scopes: List of required scopes for access (optional) + """ kwargs = { "public_key": public_key, "jwks_uri": jwks_uri, @@ -44,6 +57,6 @@ class EnvBearerAuthProvider(BearerAuthProvider): "required_scopes": required_scopes, } settings = EnvBearerAuthProviderSettings( - **{k: v for k, v in kwargs.items() if v is not NotSet} + **{k: v for k, v in kwargs.items() if v is not _NotSet} ) super().__init__(**settings.model_dump()) diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index d9530adb1..2b5381437 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -91,15 +91,15 @@ def setup_auth_middleware_and_routes( Middleware(AuthContextMiddleware), ] - required_scopes = auth.auth_settings.required_scopes or [] + required_scopes = auth.required_scopes or [] auth_routes.extend( create_auth_routes( provider=auth, - issuer_url=auth.auth_settings.issuer_url, - service_documentation_url=auth.auth_settings.service_documentation_url, - client_registration_options=auth.auth_settings.client_registration_options, - revocation_options=auth.auth_settings.revocation_options, + issuer_url=auth.issuer_url, + service_documentation_url=auth.service_documentation_url, + client_registration_options=auth.client_registration_options, + revocation_options=auth.revocation_options, ) ) diff --git a/tests/auth/providers/test_bearer_env.py b/tests/auth/providers/test_bearer_env.py index 7c81ba737..cadf7075a 100644 --- a/tests/auth/providers/test_bearer_env.py +++ b/tests/auth/providers/test_bearer_env.py @@ -10,7 +10,7 @@ def test_load_bearer_env_from_env_var(monkeypatch): mcp = FastMCP() assert mcp.auth is None - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") mcp_with_auth = FastMCP() @@ -21,7 +21,7 @@ def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatc mcp = FastMCP() assert mcp.auth is None - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") with pytest.raises( ValueError, match="Either public_key or jwks_uri must be provided" @@ -30,7 +30,7 @@ def test_load_bearer_env_from_env_var_requires_public_key_or_jwks_uri(monkeypatc def test_configure_bearer_env_from_env_var(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") monkeypatch.setenv("FASTMCP_AUTH_BEARER_ISSUER", "http://test-issuer") monkeypatch.setenv("FASTMCP_AUTH_BEARER_AUDIENCE", "test-audience") @@ -41,14 +41,13 @@ def test_configure_bearer_env_from_env_var(monkeypatch): mcp = FastMCP() assert isinstance(mcp.auth, EnvBearerAuthProvider) assert mcp.auth.public_key == "test-public-key" - assert mcp.auth.issuer == "http://test-issuer" - assert mcp.auth.auth_settings.issuer_url == AnyHttpUrl("http://test-issuer") + assert mcp.auth.issuer_url == AnyHttpUrl("http://test-issuer") assert mcp.auth.audience == "test-audience" - assert mcp.auth.auth_settings.required_scopes == ["test-scope1", "test-scope2"] + assert mcp.auth.required_scopes == ["test-scope1", "test-scope2"] def test_list_of_scopes_must_be_a_list(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_REQUIRED_SCOPES", "test-scope1") with pytest.raises(ValidationError, match="Input should be a valid list"): @@ -56,7 +55,7 @@ def test_list_of_scopes_must_be_a_list(monkeypatch): def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri") mcp = FastMCP() @@ -65,7 +64,7 @@ def test_configure_bearer_env_jwks_uri_from_env_var(monkeypatch): def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") monkeypatch.setenv("FASTMCP_AUTH_BEARER_JWKS_URI", "test-jwks-uri") @@ -74,7 +73,7 @@ def test_configure_bearer_env_public_key_and_jwks_uri_error(monkeypatch): def test_provided_auth_takes_precedence_over_env_vars(monkeypatch): - monkeypatch.setenv("FASTMCP_SERVER_AUTH_PROVIDER", "bearer_env") + monkeypatch.setenv("FASTMCP_SERVER_DEFAULT_AUTH_PROVIDER", "bearer_env") monkeypatch.setenv("FASTMCP_AUTH_BEARER_PUBLIC_KEY", "test-public-key") mcp = FastMCP(auth=BearerAuthProvider(public_key="test-public-key-2")) From 8a61b16cdadf4efea139cee6d4aa8417bfcbb849 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:14:57 -0400 Subject: [PATCH 29/34] Document exclude_args --- docs/docs.json | 15 +++++++++++---- docs/servers/tools.mdx | 21 ++++++++++++++++++++- src/fastmcp/tools/tool.py | 9 +++------ 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 14facc32c..884017db4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -46,10 +46,17 @@ "group": "Servers", "pages": [ "servers/fastmcp", - "servers/tools", - "servers/resources", - "servers/prompts", - "servers/context", + { + "group": "Core Components", + "hidden": false, + "icon": "toolbox", + "pages": [ + "servers/tools", + "servers/resources", + "servers/prompts", + "servers/context" + ] + }, "servers/openapi", "servers/proxy", "servers/composition" diff --git a/docs/servers/tools.mdx b/docs/servers/tools.mdx index 879ceab02..365a8611d 100644 --- a/docs/servers/tools.mdx +++ b/docs/servers/tools.mdx @@ -158,7 +158,7 @@ While FastMCP infers the name and description from your function, you can overri @mcp.tool( name="find_products", # Custom tool name for the LLM description="Search the product catalog with optional category filtering.", # Custom description - tags={"catalog", "search"} # Optional tags for organization/filtering + tags={"catalog", "search"}, # Optional tags for organization/filtering ) def search_products_implementation(query: str, category: str | None = None) -> list[dict]: """Internal function description (ignored if description is provided above).""" @@ -172,6 +172,25 @@ def search_products_implementation(query: str, category: str | None = None) -> l - **`tags`**: A set of strings used to categorize the tool. Clients *might* use tags to filter or group available tools. +- **`exclude_args`**: + + A list of argument names to exclude from the tool schema shown to the LLM. This is useful for arguments that are injected at runtime (such as `state`, `user_id`, or credentials) and should not be exposed to the LLM or client. Only arguments with default values can be excluded; attempting to exclude a required argument will raise an error. + + + Example: + + ```python + @mcp.tool( + name="get_user_details", + exclude_args=["user_id"] + ) + def get_user_details(user_id: str = None) -> str: + # user_id will be injected by the server, not provided by the LLM + ... + ``` + + With this configuration, `user_id` will not appear in the tool's parameter schema, but can still be set by the server or framework at runtime. + ### Async Tools FastMCP seamlessly supports both standard (`def`) and asynchronous (`async def`) functions as tools. diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index b1982c429..3d22a1810 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -102,15 +102,12 @@ class Tool(BaseModel): type_adapter = get_cached_typeadapter(fn) schema = type_adapter.json_schema() + prune_params: list[str] = [] context_kwarg = find_kwarg_by_type(fn, kwarg_type=Context) - temp_prune_params: list[str] = [] if context_kwarg: - temp_prune_params.append(context_kwarg) + prune_params.append(context_kwarg) if exclude_args: - temp_prune_params.extend(exclude_args) - prune_params: list[str] | None = ( - None if not temp_prune_params else temp_prune_params - ) + prune_params.extend(exclude_args) schema = compress_schema(schema, prune_params=prune_params) From 476f5e65067b9a9b71e21669d5e8360cd33d8be5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 18:15:34 -0400 Subject: [PATCH 30/34] Update docs.json --- docs/docs.json | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/docs.json b/docs/docs.json index 884017db4..677c743a8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -48,7 +48,6 @@ "servers/fastmcp", { "group": "Core Components", - "hidden": false, "icon": "toolbox", "pages": [ "servers/tools", From 5ac996d1345727380932c6b61b30b2234dc35695 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 20:44:56 -0400 Subject: [PATCH 31/34] Reorganize auth and use secretstr --- src/fastmcp/client/auth/__init__.py | 4 ++++ src/fastmcp/client/auth/bearer.py | 17 +++++++++++++++++ src/fastmcp/client/{auth.py => auth/oauth.py} | 9 --------- tests/auth/providers/test_bearer.py | 2 +- 4 files changed, 22 insertions(+), 10 deletions(-) create mode 100644 src/fastmcp/client/auth/__init__.py create mode 100644 src/fastmcp/client/auth/bearer.py rename src/fastmcp/client/{auth.py => auth/oauth.py} (98%) diff --git a/src/fastmcp/client/auth/__init__.py b/src/fastmcp/client/auth/__init__.py new file mode 100644 index 000000000..6ec3ecf4b --- /dev/null +++ b/src/fastmcp/client/auth/__init__.py @@ -0,0 +1,4 @@ +from .bearer import BearerAuth +from .oauth import OAuth + +__all__ = ["BearerAuth", "OAuth"] diff --git a/src/fastmcp/client/auth/bearer.py b/src/fastmcp/client/auth/bearer.py new file mode 100644 index 000000000..0c38a11b0 --- /dev/null +++ b/src/fastmcp/client/auth/bearer.py @@ -0,0 +1,17 @@ +import httpx +from pydantic import SecretStr + +from fastmcp.utilities.logging import get_logger + +__all__ = ["BearerAuth"] + +logger = get_logger(__name__) + + +class BearerAuth(httpx.Auth): + def __init__(self, token: str): + self.token = SecretStr(token) + + def auth_flow(self, request): + request.headers["Authorization"] = f"Bearer {self.token.get_secret_value()}" + yield request diff --git a/src/fastmcp/client/auth.py b/src/fastmcp/client/auth/oauth.py similarity index 98% rename from src/fastmcp/client/auth.py rename to src/fastmcp/client/auth/oauth.py index 28c2bba84..43df9d442 100644 --- a/src/fastmcp/client/auth.py +++ b/src/fastmcp/client/auth/oauth.py @@ -392,12 +392,3 @@ def OAuth( ) return oauth_provider - - -class BearerAuth(httpx.Auth): - def __init__(self, token: str): - self.token = token - - def auth_flow(self, request): - request.headers["Authorization"] = f"Bearer {self.token}" - yield request diff --git a/tests/auth/providers/test_bearer.py b/tests/auth/providers/test_bearer.py index ff127f727..aed70af71 100644 --- a/tests/auth/providers/test_bearer.py +++ b/tests/auth/providers/test_bearer.py @@ -6,7 +6,7 @@ import pytest from pytest_httpx import HTTPXMock from fastmcp import Client, FastMCP -from fastmcp.client.auth import BearerAuth +from fastmcp.client.auth.bearer import BearerAuth from fastmcp.server.auth.providers.bearer import ( BearerAuthProvider, JWKData, From 16e73f86df44a9b0a94b08b2c28338a74b8b1ef7 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 20:45:04 -0400 Subject: [PATCH 32/34] Add client doc --- docs/clients/auth/bearer.mdx | 88 ++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 docs/clients/auth/bearer.mdx diff --git a/docs/clients/auth/bearer.mdx b/docs/clients/auth/bearer.mdx new file mode 100644 index 000000000..656bd9ee7 --- /dev/null +++ b/docs/clients/auth/bearer.mdx @@ -0,0 +1,88 @@ +--- +title: Bearer Authentication +sidebarTitle: Bearer Auth +description: Authenticate your FastMCP client using pre-existing OAuth 2.0 Bearer tokens. +icon: key +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +You can configure your FastMCP client to use **bearer authentication** by supplying a valid access token. This is most appropriate for service accounts, long-lived API keys, CI/CD, applications where authentication is managed separately, or other non-interactive authentication methods. + + +A Bearer token is a JSON Web Token (JWT) that is used to authenticate a request. It is most commonly used in the `Authorization` header of an HTTP request, using the `Bearer` scheme: + +```http +Authorization: Bearer +``` + + +## Client Usage + +The most straightforward way to use a pre-existing Bearer token is to provide it as a string to the `auth` parameter of the `fastmcp.Client` or transport instance. FastMCP will automatically format it correctly for the `Authorization` header and bearer scheme. + + +If you're using a string token, do not include the `Bearer` prefix. FastMCP will add it for you. + + +```python {6} +from fastmcp import Client + +async def main(): + async with Client( + 'https://fastmcp.cloud/mcp', + auth="", + ) as client: + await client.ping() +``` + +You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`: + +```python {6} +from fastmcp import Client +from fastmcp.client.transports import StreamableHttpTransport + +transport = StreamableHttpTransport( + "http://fastmcp.cloud/mcp", + auth="", +) + +async def main(): + async with Client(transport) as client: + await client.ping() +``` + +## `BearerAuth` Helper + +If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx.Auth` interface. + +```python {7} +from fastmcp import Client +from fastmcp.client.auth import BearerAuth + +async def main(): + async with Client( + "https://fastmcp.cloud/mcp", + auth=BearerAuth(token=""), + ) as client: + await client.ping() +``` + +## Custom Headers + +If the MCP server expects a custom header or token scheme, you can manually set the client's `headers` instead of using the `auth` parameter: + +```python {6-8} +from fastmcp import Client + +async def main(): + async with Client( + "https://fastmcp.cloud/mcp", + headers={ + 'X-API-Key': '', + }, + ) as client: + await client.ping() +``` From 132db32e51349f1f157dcc92693c113d75f061b5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 21:33:33 -0400 Subject: [PATCH 33/34] Add auth docs --- docs/clients/auth/bearer.mdx | 55 ++++----- docs/clients/auth/oauth.mdx | 116 ++++++++++++++++++ docs/deployment/authentication.mdx | 15 --- docs/docs.json | 34 ++++-- docs/servers/auth/bearer.mdx | 183 ++++++++++++++++++++++++++++ src/fastmcp/client/__init__.py | 3 +- src/fastmcp/client/transports.py | 2 +- src/fastmcp/server/auth/__init__.py | 4 + tests/auth/test_oauth_client.py | 4 +- 9 files changed, 356 insertions(+), 60 deletions(-) create mode 100644 docs/clients/auth/oauth.mdx delete mode 100644 docs/deployment/authentication.mdx create mode 100644 docs/servers/auth/bearer.mdx diff --git a/docs/clients/auth/bearer.mdx b/docs/clients/auth/bearer.mdx index 656bd9ee7..68459ebf6 100644 --- a/docs/clients/auth/bearer.mdx +++ b/docs/clients/auth/bearer.mdx @@ -1,5 +1,5 @@ --- -title: Bearer Authentication +title: Bearer Token Authentication sidebarTitle: Bearer Auth description: Authenticate your FastMCP client using pre-existing OAuth 2.0 Bearer tokens. icon: key @@ -9,8 +9,11 @@ import { VersionBadge } from "/snippets/version-badge.mdx" -You can configure your FastMCP client to use **bearer authentication** by supplying a valid access token. This is most appropriate for service accounts, long-lived API keys, CI/CD, applications where authentication is managed separately, or other non-interactive authentication methods. + +Bearer Token authentication is only relevant for HTTP-based transports. + +You can configure your FastMCP client to use **bearer authentication** by supplying a valid access token. This is most appropriate for service accounts, long-lived API keys, CI/CD, applications where authentication is managed separately, or other non-interactive authentication methods. A Bearer token is a JSON Web Token (JWT) that is used to authenticate a request. It is most commonly used in the `Authorization` header of an HTTP request, using the `Bearer` scheme: @@ -27,62 +30,52 @@ The most straightforward way to use a pre-existing Bearer token is to provide it If you're using a string token, do not include the `Bearer` prefix. FastMCP will add it for you. -```python {6} +```python {4} from fastmcp import Client -async def main(): - async with Client( - 'https://fastmcp.cloud/mcp', - auth="", - ) as client: - await client.ping() +async with Client( + "https://fastmcp.cloud/mcp", auth="" +) as client: + await client.ping() ``` You can also supply a Bearer token to a transport instance, such as `StreamableHttpTransport` or `SSETransport`: -```python {6} +```python {5} from fastmcp import Client from fastmcp.client.transports import StreamableHttpTransport transport = StreamableHttpTransport( - "http://fastmcp.cloud/mcp", - auth="", + "http://fastmcp.cloud/mcp", auth="" ) -async def main(): - async with Client(transport) as client: - await client.ping() +async with Client(transport) as client: + await client.ping() ``` ## `BearerAuth` Helper If you prefer to be more explicit and not rely on FastMCP to transform your string token, you can use the `BearerAuth` class yourself, which implements the `httpx.Auth` interface. -```python {7} +```python {5} from fastmcp import Client from fastmcp.client.auth import BearerAuth -async def main(): - async with Client( - "https://fastmcp.cloud/mcp", - auth=BearerAuth(token=""), - ) as client: - await client.ping() +async with Client( + "https://fastmcp.cloud/mcp", auth=BearerAuth(token="") +) as client: + await client.ping() ``` ## Custom Headers If the MCP server expects a custom header or token scheme, you can manually set the client's `headers` instead of using the `auth` parameter: -```python {6-8} +```python {4} from fastmcp import Client -async def main(): - async with Client( - "https://fastmcp.cloud/mcp", - headers={ - 'X-API-Key': '', - }, - ) as client: - await client.ping() +async with Client( + "https://fastmcp.cloud/mcp", headers={"X-API-Key": ""} +) as client: + await client.ping() ``` diff --git a/docs/clients/auth/oauth.mdx b/docs/clients/auth/oauth.mdx new file mode 100644 index 000000000..e1973edce --- /dev/null +++ b/docs/clients/auth/oauth.mdx @@ -0,0 +1,116 @@ +--- +title: OAuth Authentication +sidebarTitle: OAuth +description: Authenticate your FastMCP client with servers using the OAuth 2.0 Authorization Code Grant, including user interaction via a web browser. +icon: window +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + + +OAuth authentication is only relevant for HTTP-based transports and requires user interaction via a web browser. + + +When your FastMCP client needs to access an MCP server protected by OAuth 2.0, and the process requires user interaction (like logging in and granting consent), you should use the Authorization Code Flow. FastMCP provides the `fastmcp.client.auth.OAuth` helper to simplify this entire process. + +This flow is common for user-facing applications where the application acts on behalf of the user. + +## Client Usage + + +### Default Configuration + +The simplest way to use OAuth is to pass the string `"oauth"` to the `auth` parameter of the `Client` or transport instance. FastMCP will automatically configure the client to use OAuth with default settings: + +```python {4} +from fastmcp import Client + +# Uses default OAuth settings +async with Client("https://fastmcp.cloud/mcp", auth="oauth") as client: + await client.ping() +``` + + +### `OAuth` Helper + +To fully configure the OAuth flow, use the `OAuth` helper and pass it to the `auth` parameter of the `Client` or transport instance. `OAuth` manages the complexities of the OAuth 2.0 Authorization Code Grant with PKCE (Proof Key for Code Exchange) for enhanced security, and implements the full `httpx.Auth` interface. + +```python {2, 4, 6} +from fastmcp import Client +from fastmcp.client.auth import OAuth + +oauth = OAuth(mcp_url="https://fastmcp.cloud/mcp") + +async with Client("https://fastmcp.cloud/mcp", auth=oauth) as client: + await client.ping() +``` + +#### `OAuth` Parameters + +- **`mcp_url`** (`str`): The full URL of the target MCP server endpoint. Used to discover OAuth server metadata +- **`scopes`** (`str | list[str]`, optional): OAuth scopes to request. Can be space-separated string or list of strings +- **`client_name`** (`str`, optional): Client name for dynamic registration. Defaults to `"FastMCP Client"` +- **`token_storage_cache_dir`** (`Path`, optional): Token cache directory. Defaults to `~/.fastmcp/oauth-mcp-client-cache/` +- **`additional_client_metadata`** (`dict[str, Any]`, optional): Extra metadata for client registration + + +## OAuth Flow + +The OAuth flow is triggered when you use a FastMCP `Client` configured to use OAuth. + + + +The client first checks the `token_storage_cache_dir` for existing, valid tokens for the target server. If one is found, it will be used to authenticate the client. + + +If no valid tokens exist, the client attempts to discover the OAuth server's endpoints using a well-known URI (e.g., `/.well-known/oauth-authorization-server`) based on the `mcp_url`. + + +If the OAuth server supports it and the client isn't already registered (or credentials aren't cached), the client performs dynamic client registration according to RFC 7591. + + +A temporary local HTTP server is started on an available port. This server's address (e.g., `http://127.0.0.1:/callback`) acts as the `redirect_uri` for the OAuth flow. + + +The user's default web browser is automatically opened, directing them to the OAuth server's authorization endpoint. The user logs in and grants (or denies) the requested `scopes`. + + +Upon approval, the OAuth server redirects the user's browser to the local callback server with an `authorization_code`. The client captures this code and exchanges it with the OAuth server's token endpoint for an `access_token` (and often a `refresh_token`) using PKCE for security. + + +The obtained tokens are saved to the `token_storage_cache_dir` for future use, eliminating the need for repeated browser interactions. + + +The access token is automatically included in the `Authorization` header for requests to the MCP server. + + +If the access token expires, the client will automatically use the refresh token to get a new access token. + + + +## Token Management + +### Token Storage + +OAuth access tokens are automatically cached in `~/.fastmcp/oauth-mcp-client-cache/` and persist between application runs. Files are keyed by the OAuth server's base URL. + +### Managing Cache + +To clear the tokens for a specific server, instantiate a `FileTokenStorage` instance and call the `clear` method: + +```python +from fastmcp.client.auth import FileTokenStorage + +storage = FileTokenStorage(server_url="https://fastmcp.cloud/mcp") +await storage.clear() +``` + +To clear *all* tokens for all servers, call the `clear_all` method on the `FileTokenStorage` class: + +```python +from fastmcp.client.auth import FileTokenStorage + +FileTokenStorage.clear_all() +``` \ No newline at end of file diff --git a/docs/deployment/authentication.mdx b/docs/deployment/authentication.mdx deleted file mode 100644 index 25789fdc0..000000000 --- a/docs/deployment/authentication.mdx +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Authentication -sidebarTitle: Authentication -description: Secure your FastMCP server with authentication. -icon: lock ---- -import { VersionBadge } from '/snippets/version-badge.mdx' - - - -This document will cover how to implement authentication for your FastMCP servers. - -FastMCP leverages the OAuth 2.0 support provided by the underlying Model Context Protocol (MCP) SDK. - -For now, refer to the [MCP Server Authentication documentation](/servers/fastmcp#authentication) for initial details and the [official MCP SDK documentation](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for more. diff --git a/docs/docs.json b/docs/docs.json index 677c743a8..a4c24b519 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -56,18 +56,24 @@ "servers/context" ] }, + { + "group": "Authentication", + "icon": "shield-check", + "pages": [ + "servers/auth/bearer" + ] + }, "servers/openapi", "servers/proxy", - "servers/composition" - ] - }, - { - "group": "Deployment", - "pages": [ - "deployment/running-server", - "deployment/asgi", - "deployment/authentication", - "deployment/cli" + "servers/composition", + { + "group": "Deployment", + "pages": [ + "deployment/running-server", + "deployment/asgi", + "deployment/cli" + ] + } ] }, { @@ -75,6 +81,14 @@ "pages": [ "clients/client", "clients/transports", + { + "group": "Authentication", + "icon": "user-shield", + "pages": [ + "clients/auth/bearer", + "clients/auth/oauth" + ] + }, "clients/advanced-features" ] }, diff --git a/docs/servers/auth/bearer.mdx b/docs/servers/auth/bearer.mdx new file mode 100644 index 000000000..712c55518 --- /dev/null +++ b/docs/servers/auth/bearer.mdx @@ -0,0 +1,183 @@ +--- +title: Bearer Token Authentication +sidebarTitle: Bearer Auth +description: Secure your FastMCP server's HTTP endpoints by validating JWT Bearer tokens. +icon: key +--- + +import { VersionBadge } from "/snippets/version-badge.mdx" + + + +Authentication and authorization are only relevant for HTTP-based transports. + + +Bearer Token authentication is a common way to secure HTTP-based APIs. In this model, the client sends a token (usually a JSON Web Token or JWT) in the `Authorization` header with the "Bearer" scheme. The server then validates this token to grant or deny access. + +FastMCP supports Bearer Token authentication for its HTTP-based transports (`streamable-http` and `sse`), allowing you to protect your server from unauthorized access. + +## Authentication Strategy + +FastMCP uses **asymmetric encryption** for token validation, which provides a clean security separation between token issuers and FastMCP servers. This approach means: + +- **No shared secrets**: Your FastMCP server never needs access to private keys or client secrets +- **Public key verification**: The server only needs a public key (or JWKS endpoint) to verify token signatures +- **Secure token issuance**: Tokens are signed by an external service using a private key that never leaves the issuer +- **Scalable architecture**: Multiple FastMCP servers can validate tokens without coordinating secrets + +This design allows you to integrate FastMCP servers into existing authentication infrastructures without compromising security boundaries. + +## Configuration + +To enable Bearer Token validation on your FastMCP server, use the `BearerAuthProvider` class. This provider validates incoming JWTs by verifying signatures, checking expiration, and optionally validating claims. + + +The `BearerAuthProvider` validates tokens; it does **not** issue them (or implement any part of an OAuth flow). You'll need to generate tokens separately, either using FastMCP utilities or an external Identity Provider (IdP) or OAuth 2.0 Authorization Server. + + +### Basic Setup + +To configure bearer token authentication, instantiate a `BearerAuthProvider` instance and pass it to the `auth` parameter of the `FastMCP` instance. + +The `BearerAuthProvider` requires either a static public key or a JWKS URI (but not both!) in order to verify the token's signature. All other parameters are optional -- if they are provided, they will be used as additional validation criteria. + +```python {2, 10} +from fastmcp import FastMCP +from fastmcp.server.auth import BearerAuthProvider + +auth = BearerAuthProvider( + jwks_uri="https://my-identity-provider.com/.well-known/jwks.json", + issuer="https://my-identity-provider.com/", + audience="my-mcp-server" +) + +mcp = FastMCP(name="My MCP Server", auth=auth) +``` + +### Configuration Parameters + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `public_key` | `str` | If `jwks_uri` is not provided | RSA public key in PEM format for static key validation | +| `jwks_uri` | `str` | If `public_key` is not provided | URL for JSON Web Key Set endpoint | +| `issuer` | `str` | No | Expected JWT `iss` claim value | +| `audience` | `str` | No | Expected JWT `aud` claim value | +| `required_scopes` | `list[str]` | No | Global scopes required for all requests | + +#### Public Key + +If you have a public key in PEM format, you can provide it to the `BearerAuthProvider` as a string. + +```python {12} +from fastmcp.server.auth import BearerAuthProvider +import inspect + +public_key_pem = inspect.cleandoc( + """ + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAy... + -----END PUBLIC KEY----- + """ +) + +auth = BearerAuthProvider(public_key=public_key_pem) +``` + +#### JWKS URI + +```python +provider = BearerAuthProvider( + jwks_uri="https://idp.example.com/.well-known/jwks.json" +) +``` + + +JWKS is recommended for production as it supports automatic key rotation and multiple signing keys. + + +## Generating Tokens + +For development and testing, FastMCP provides the `RSAKeyPair` utility class to generate tokens without needing an external OAuth provider. + + +The `RSAKeyPair` utility is intended for development and testing only. For production, use a proper OAuth 2.0 Authorization Server or Identity Provider. + +### Basic Token Generation + +```python +from fastmcp import FastMCP +from fastmcp.server.auth import BearerAuthProvider +from fastmcp.server.auth.providers.bearer import RSAKeyPair + +# Generate a new key pair +key_pair = RSAKeyPair.generate() + +# Configure the auth provider with the public key +auth = BearerAuthProvider( + public_key=key_pair.public_key, + issuer="https://dev.example.com", + audience="my-dev-server" +) + +mcp = FastMCP(name="Development Server", auth=auth) + +# Generate a token for testing +token = key_pair.create_token( + subject="dev-user", + issuer="https://dev.example.com", + audience="my-dev-server", + scopes=["read", "write"] +) + +print(f"Test token: {token}") +``` + +### Token Creation Parameters + +The `create_token()` method accepts these parameters: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `subject` | `str` | `"fastmcp-user"` | JWT subject claim (usually user ID) | +| `issuer` | `str` | `"https://fastmcp.example.com"` | JWT issuer claim | +| `audience` | `str` | `None` | JWT audience claim | +| `scopes` | `list[str]` | `None` | OAuth scopes to include | +| `expires_in_seconds` | `int` | `3600` | Token expiration time | +| `additional_claims` | `dict` | `None` | Extra claims to include | +| `kid` | `str` | `None` | Key ID for JWKS lookup | + + +## Accessing Token Claims + +Once authenticated, your tools, resources, or prompts can access token information using the `get_access_token()` dependency function: + +```python +from fastmcp import FastMCP, Context, ToolError +from fastmcp.server.dependencies import get_access_token, AccessToken + +@mcp.tool() +async def get_my_data(ctx: Context) -> dict: + access_token: AccessToken = get_access_token() + + user_id = access_token.client_id # From JWT 'sub' or 'client_id' claim + user_scopes = access_token.scopes + + if "data:read_sensitive" not in user_scopes: + raise ToolError("Insufficient permissions: 'data:read_sensitive' scope required.") + + return { + "user": user_id, + "sensitive_data": f"Private data for {user_id}", + "granted_scopes": user_scopes + } +``` + +### AccessToken Properties + +| Property | Type | Description | +|----------|------|-------------| +| `token` | `str` | The raw JWT string | +| `client_id` | `str` | Authenticated principal identifier | +| `scopes` | `list[str]` | Granted scopes | +| `expires_at` | `datetime \| None` | Token expiration timestamp | + diff --git a/src/fastmcp/client/__init__.py b/src/fastmcp/client/__init__.py index 9397be295..9483fb6e5 100644 --- a/src/fastmcp/client/__init__.py +++ b/src/fastmcp/client/__init__.py @@ -11,7 +11,7 @@ from .transports import ( FastMCPTransport, StreamableHttpTransport, ) -from .auth import OAuth +from .auth import OAuth, BearerAuth __all__ = [ "Client", @@ -26,4 +26,5 @@ __all__ = [ "FastMCPTransport", "StreamableHttpTransport", "OAuth", + "BearerAuth", ] diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 5ab1d9a9e..e8a32b018 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -36,7 +36,7 @@ from mcp.shared.memory import create_connected_server_and_client_session from pydantic import AnyUrl from typing_extensions import Unpack -from fastmcp.client.auth import OAuth +from fastmcp.client.auth.oauth import OAuth from fastmcp.server.dependencies import get_http_headers from fastmcp.server.server import FastMCP from fastmcp.utilities.logging import get_logger diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py index e69de29bb..9c3055c47 100644 --- a/src/fastmcp/server/auth/__init__.py +++ b/src/fastmcp/server/auth/__init__.py @@ -0,0 +1,4 @@ +from .providers.bearer import BearerAuthProvider + + +__all__ = ["BearerAuthProvider"] diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index 71db7fe47..70581752e 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -5,7 +5,7 @@ from urllib.parse import parse_qs, urlparse import httpx import pytest -import fastmcp.client.auth # Import module, not the function directly +import fastmcp.client.auth.oauth # Import module, not the function directly from fastmcp.client import Client from fastmcp.client.transports import StreamableHttpTransport from fastmcp.server.auth.auth import ClientRegistrationOptions @@ -196,7 +196,7 @@ def client_with_headless_oauth( with patch("fastmcp.client.auth.OAuth", side_effect=headless_oauth): client = Client( transport=StreamableHttpTransport(streamable_http_server), - auth=fastmcp.client.auth.OAuth(mcp_url=streamable_http_server), + auth=fastmcp.client.auth.oauth.OAuth(mcp_url=streamable_http_server), ) yield client From 60902b5de9137611f74334623112658beb2ab231 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Sun, 1 Jun 2025 21:37:27 -0400 Subject: [PATCH 34/34] Fix patch address --- tests/auth/test_oauth_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/auth/test_oauth_client.py b/tests/auth/test_oauth_client.py index 70581752e..be3328a07 100644 --- a/tests/auth/test_oauth_client.py +++ b/tests/auth/test_oauth_client.py @@ -193,7 +193,7 @@ def client_with_headless_oauth( raise ValueError("mcp_url is required") return HeadlessOAuthProvider(mcp_url) - with patch("fastmcp.client.auth.OAuth", side_effect=headless_oauth): + with patch("fastmcp.client.auth.oauth.OAuth", side_effect=headless_oauth): client = Client( transport=StreamableHttpTransport(streamable_http_server), auth=fastmcp.client.auth.oauth.OAuth(mcp_url=streamable_http_server),