From 99ed499f139db609086fb1982a85ae58fd245c3c Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 10:36:29 +0800 Subject: [PATCH 01/17] fix: proxy mcp server should tolerate METHOD_NOT_FOUND --- src/fastmcp/server/proxy.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index e4c31e5c2..99708b654 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -10,7 +10,9 @@ from mcp.types import ( ImageContent, TextContent, TextResourceContents, + METHOD_NOT_FOUND, ) +from mcp.shared.exceptions import McpError from pydantic.networks import AnyUrl from fastmcp.client import Client @@ -173,7 +175,12 @@ class FastMCPProxy(FastMCP): tools = await super().get_tools() async with self.client: - for tool in await self.client.list_tools(): + try: + client_tools =await self.client.list_tools() + except McpError as e: + if e.error.code == METHOD_NOT_FOUND: + client_tools = [] + for tool in client_tools: tool_proxy = await ProxyTool.from_client(self.client, tool) tools[tool_proxy.name] = tool_proxy @@ -183,7 +190,12 @@ class FastMCPProxy(FastMCP): resources = await super().get_resources() async with self.client: - for resource in await self.client.list_resources(): + try: + client_resources = await self.client.list_resources() + except McpError as e: + if e.error.code == METHOD_NOT_FOUND: + client_resources = [] + for resource in client_resources: resource_proxy = await ProxyResource.from_client(self.client, resource) resources[str(resource_proxy.uri)] = resource_proxy @@ -193,7 +205,12 @@ class FastMCPProxy(FastMCP): templates = await super().get_resource_templates() async with self.client: - for template in await self.client.list_resource_templates(): + try: + client_templates = await self.client.list_resource_templates() + except McpError as e: + if e.error.code == METHOD_NOT_FOUND: + client_templates = [] + for template in client_templates: template_proxy = await ProxyTemplate.from_client(self.client, template) templates[template_proxy.uri_template] = template_proxy @@ -203,7 +220,12 @@ class FastMCPProxy(FastMCP): prompts = await super().get_prompts() async with self.client: - for prompt in await self.client.list_prompts(): + try: + client_prompts = await self.client.list_prompts() + except McpError as e: + if e.error.code == METHOD_NOT_FOUND: + client_prompts = [] + for prompt in client_prompts: prompt_proxy = await ProxyPrompt.from_client(self.client, prompt) prompts[prompt_proxy.name] = prompt_proxy return prompts From 0a29d59813c9c350172252bbde1b769657fe77e7 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 11:21:34 +0800 Subject: [PATCH 02/17] fix client nested context manager --- src/fastmcp/client/client.py | 42 +++++++++++++++--------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index c49472d2b..59750558a 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -44,8 +44,8 @@ class Client: read_timeout_seconds: datetime.timedelta | None = None, ): self.transport = infer_transport(transport) - self._session: ClientSession | None = None - self._session_cms: list[AbstractAsyncContextManager[ClientSession]] = [] + # stack to record nested context manager, None is pushed if reuse existing session + self._session_cms: list[(AbstractAsyncContextManager[ClientSession], ClientSession)] = [] self._session_kwargs: SessionKwargs = { "sampling_callback": None, @@ -64,11 +64,11 @@ class Client: @property def session(self) -> ClientSession: """Get the current active session. Raises RuntimeError if not connected.""" - if self._session is None: + if not self._session_cms: raise RuntimeError( "Client is not connected. Use 'async with client:' context manager first." ) - return self._session + self._session_cms[-1][1] def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" @@ -82,32 +82,24 @@ class Client: def is_connected(self) -> bool: """Check if the client is currently connected.""" - return self._session is not None + return len(self._session_cms) > 0 async def __aenter__(self): - if self.is_connected(): - # We're already connected, no need to add None to the session_cms list - return self - - try: + if self._session_cms: + # share the current session, push a None as cms to avoid close it in aexit + _, session = self._session_cms[-1] + self._session_cms.append((None, session)) + else: + # create new session session_cm = self.transport.connect_session(**self._session_kwargs) - self._session_cms.append(session_cm) - self._session = await self._session_cms[-1].__aenter__() - return self - except Exception as e: - # Ensure cleanup if __aenter__ fails partially - self._session = None - if self._session_cms: - self._session_cms.pop() - raise ConnectionError( - f"Failed to connect using {self.transport}: {e}" - ) from e + session = await session_cm.__aenter__() + self._session_cms.append((session_cm, session)) + return self async def __aexit__(self, exc_type, exc_val, exc_tb): - if self._session_cms: - await self._session_cms[-1].__aexit__(exc_type, exc_val, exc_tb) - self._session = None - self._session_cms.pop() + cm, _ = self._session_cms.pop() + if cm is not None: + await cm.__aexit__(exc_type, exc_val, exc_tb) # --- MCP Client Methods --- async def ping(self) -> None: From 4bb3503b63cbf7f56bcef4f6242a34dd0bcd2583 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 11:24:33 +0800 Subject: [PATCH 03/17] Update src/fastmcp/server/proxy.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/server/proxy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 99708b654..39bbc5435 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -176,7 +176,7 @@ class FastMCPProxy(FastMCP): async with self.client: try: - client_tools =await self.client.list_tools() + client_tools = await self.client.list_tools() except McpError as e: if e.error.code == METHOD_NOT_FOUND: client_tools = [] From bbc7480189d9866a6e4a07827708f10bb1b9f0c9 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 11:25:47 +0800 Subject: [PATCH 04/17] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/fastmcp/server/proxy.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 39bbc5435..71964e1ac 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -180,6 +180,8 @@ class FastMCPProxy(FastMCP): except McpError as e: if e.error.code == METHOD_NOT_FOUND: client_tools = [] + else: + raise e for tool in client_tools: tool_proxy = await ProxyTool.from_client(self.client, tool) tools[tool_proxy.name] = tool_proxy @@ -195,6 +197,8 @@ class FastMCPProxy(FastMCP): except McpError as e: if e.error.code == METHOD_NOT_FOUND: client_resources = [] + else: + raise e for resource in client_resources: resource_proxy = await ProxyResource.from_client(self.client, resource) resources[str(resource_proxy.uri)] = resource_proxy @@ -210,6 +214,8 @@ class FastMCPProxy(FastMCP): except McpError as e: if e.error.code == METHOD_NOT_FOUND: client_templates = [] + else: + raise e for template in client_templates: template_proxy = await ProxyTemplate.from_client(self.client, template) templates[template_proxy.uri_template] = template_proxy @@ -225,6 +231,8 @@ class FastMCPProxy(FastMCP): except McpError as e: if e.error.code == METHOD_NOT_FOUND: client_prompts = [] + else: + raise e for prompt in client_prompts: prompt_proxy = await ProxyPrompt.from_client(self.client, prompt) prompts[prompt_proxy.name] = prompt_proxy From 6aab8040136b7205851e8109475e9ba4663d0c17 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 11:34:42 +0800 Subject: [PATCH 05/17] fix ruff --- src/fastmcp/server/proxy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index 71964e1ac..b3d9d8dce 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -3,16 +3,16 @@ from urllib.parse import quote import mcp.types from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.shared.exceptions import McpError from mcp.types import ( + METHOD_NOT_FOUND, BlobResourceContents, EmbeddedResource, GetPromptResult, ImageContent, TextContent, TextResourceContents, - METHOD_NOT_FOUND, ) -from mcp.shared.exceptions import McpError from pydantic.networks import AnyUrl from fastmcp.client import Client From 5e86c63bd8e843308ae9a55f032a7f4e9a599e65 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 11:37:27 +0800 Subject: [PATCH 06/17] fix format --- src/fastmcp/client/client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 59750558a..b5476178f 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -45,7 +45,9 @@ class Client: ): self.transport = infer_transport(transport) # stack to record nested context manager, None is pushed if reuse existing session - self._session_cms: list[(AbstractAsyncContextManager[ClientSession], ClientSession)] = [] + self._session_cms: list[ + (AbstractAsyncContextManager[ClientSession], ClientSession) + ] = [] self._session_kwargs: SessionKwargs = { "sampling_callback": None, From 5a57f16989a40145a9bb99b01eadb3055899bf0c Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 11:44:55 +0800 Subject: [PATCH 07/17] fix --- src/fastmcp/client/client.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index b5476178f..3aad3378a 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,7 +1,7 @@ import datetime from contextlib import AbstractAsyncContextManager from pathlib import Path -from typing import Any, Literal, cast, overload +from typing import Any, Literal, cast, overload, Tuple import mcp.types from mcp import ClientSession @@ -46,7 +46,7 @@ class Client: self.transport = infer_transport(transport) # stack to record nested context manager, None is pushed if reuse existing session self._session_cms: list[ - (AbstractAsyncContextManager[ClientSession], ClientSession) + Tuple[AbstractAsyncContextManager[ClientSession] | None, ClientSession] ] = [] self._session_kwargs: SessionKwargs = { @@ -70,7 +70,8 @@ class Client: raise RuntimeError( "Client is not connected. Use 'async with client:' context manager first." ) - self._session_cms[-1][1] + _, session = self._session_cms[-1] + return session def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" @@ -88,7 +89,7 @@ class Client: async def __aenter__(self): if self._session_cms: - # share the current session, push a None as cms to avoid close it in aexit + # share the current session, push a None as context manager to avoid close it in aexit _, session = self._session_cms[-1] self._session_cms.append((None, session)) else: From 3fb036cf897e9f917feb18f0aba4b9e6d9702966 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 11:51:23 +0800 Subject: [PATCH 08/17] fix format --- src/fastmcp/client/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 3aad3378a..4200d4d8d 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -1,7 +1,7 @@ import datetime from contextlib import AbstractAsyncContextManager from pathlib import Path -from typing import Any, Literal, cast, overload, Tuple +from typing import Any, Literal, cast, overload import mcp.types from mcp import ClientSession @@ -46,7 +46,7 @@ class Client: self.transport = infer_transport(transport) # stack to record nested context manager, None is pushed if reuse existing session self._session_cms: list[ - Tuple[AbstractAsyncContextManager[ClientSession] | None, ClientSession] + tuple[AbstractAsyncContextManager[ClientSession] | None, ClientSession] ] = [] self._session_kwargs: SessionKwargs = { From 0af58a85a6bcbde9e120a6a7d1f60e73eea7e5a4 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 13:04:07 +0800 Subject: [PATCH 09/17] simply nesting handling --- src/fastmcp/client/client.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 4200d4d8d..82f77dd46 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -44,10 +44,9 @@ class Client: read_timeout_seconds: datetime.timedelta | None = None, ): self.transport = infer_transport(transport) - # stack to record nested context manager, None is pushed if reuse existing session - self._session_cms: list[ - tuple[AbstractAsyncContextManager[ClientSession] | None, ClientSession] - ] = [] + self._session: ClientSession | None = None + self._session_cms: AbstractAsyncContextManager[ClientSession] | None = None + self._nesting_counter: int = 0 self._session_kwargs: SessionKwargs = { "sampling_callback": None, @@ -66,12 +65,11 @@ class Client: @property def session(self) -> ClientSession: """Get the current active session. Raises RuntimeError if not connected.""" - if not self._session_cms: + if not self._session: raise RuntimeError( "Client is not connected. Use 'async with client:' context manager first." ) - _, session = self._session_cms[-1] - return session + return self._session def set_roots(self, roots: RootsList | RootsHandler) -> None: """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" @@ -85,24 +83,24 @@ class Client: def is_connected(self) -> bool: """Check if the client is currently connected.""" - return len(self._session_cms) > 0 + return self._session is not None async def __aenter__(self): - if self._session_cms: - # share the current session, push a None as context manager to avoid close it in aexit - _, session = self._session_cms[-1] - self._session_cms.append((None, session)) - else: + if self._nesting_counter == 0: # create new session - session_cm = self.transport.connect_session(**self._session_kwargs) - session = await session_cm.__aenter__() - self._session_cms.append((session_cm, session)) + self._session_cm = self.transport.connect_session(**self._session_kwargs) + self._session = await self._session_cm.__aenter__() + + self._nesting_counter += 1 return self async def __aexit__(self, exc_type, exc_val, exc_tb): - cm, _ = self._session_cms.pop() - if cm is not None: - await cm.__aexit__(exc_type, exc_val, exc_tb) + self._nesting_counter -= 0 + + if self._nesting_counter == 0 and self._session_cms is not None: + await self._session_cms.__aexit__(exc_type, exc_val, exc_tb) + self._session_cms = None + self._session = None # --- MCP Client Methods --- async def ping(self) -> None: From 5bbad74044d0748ba92c8b2992766eb30e869ecc Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 13:14:46 +0800 Subject: [PATCH 10/17] add unit test --- src/fastmcp/client/client.py | 10 ++++----- tests/client/test_client.py | 39 +++++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 82f77dd46..1bf34ba4c 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -45,7 +45,7 @@ class Client: ): self.transport = infer_transport(transport) self._session: ClientSession | None = None - self._session_cms: AbstractAsyncContextManager[ClientSession] | None = None + self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None self._nesting_counter: int = 0 self._session_kwargs: SessionKwargs = { @@ -95,11 +95,11 @@ class Client: return self async def __aexit__(self, exc_type, exc_val, exc_tb): - self._nesting_counter -= 0 + self._nesting_counter -= 1 - if self._nesting_counter == 0 and self._session_cms is not None: - await self._session_cms.__aexit__(exc_type, exc_val, exc_tb) - self._session_cms = None + if self._nesting_counter == 0 and self._session_cm is not None: + await self._session_cm.__aexit__(exc_type, exc_val, exc_tb) + self._session_cm = None self._session = None # --- MCP Client Methods --- diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 17c299e8c..10eee4c35 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,10 +1,15 @@ from typing import cast +from typing_extensions import Unpack +from collections.abc import AsyncIterator +from mcp import ClientSession +import contextlib +from mcp.shared.memory import create_client_server_memory_streams import pytest from pydantic import AnyUrl from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport +from fastmcp.client.transports import FastMCPTransport, ClientTransport, SessionKwargs from fastmcp.server.server import FastMCP @@ -159,6 +164,38 @@ async def test_client_connection(fastmcp_server): # After connection assert not client.is_connected() +async def test_client_nested_context_manager(fastmcp_server): + """Test that the client connects and disconnects once in nested context manager.""" + class MockTransport(ClientTransport): + def __init__(self): + self._connected = False + + @contextlib.asynccontextmanager + async def connect_session( + self, **session_kwargs: Unpack[SessionKwargs], + ) -> AsyncIterator[ClientSession]: + assert not self._connected, "Transport is connected multiple times" + self._connected = True + async with create_client_server_memory_streams() as ( + _, + server_streams, + ): + yield ClientSession(*server_streams) + + client = Client(transport=MockTransport()) + + # Before connection + assert not client.is_connected() + + # During connection + async with client: + assert client.is_connected() + + async with client: + assert client.is_connected() + + # After connection + assert not client.is_connected() async def test_resource_template(fastmcp_server): """Test using a resource template with InMemoryClient.""" From e962f4d3120fa01fdc0aa80867f74bffeee03973 Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 13:18:11 +0800 Subject: [PATCH 11/17] fix test --- tests/client/test_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 10eee4c35..3acb23f63 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -194,6 +194,9 @@ async def test_client_nested_context_manager(fastmcp_server): async with client: assert client.is_connected() + async with client: + assert client.is_connected() + # After connection assert not client.is_connected() From ad41ca7dc8211826f6de8e67a21c69fa8df092ac Mon Sep 17 00:00:00 2001 From: yihuang Date: Mon, 21 Apr 2025 13:21:11 +0800 Subject: [PATCH 12/17] fix format --- src/fastmcp/client/client.py | 2 +- tests/client/test_client.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 1bf34ba4c..7fdd21899 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -65,7 +65,7 @@ class Client: @property def session(self) -> ClientSession: """Get the current active session. Raises RuntimeError if not connected.""" - if not self._session: + if self._session is None: raise RuntimeError( "Client is not connected. Use 'async with client:' context manager first." ) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 3acb23f63..5165c2a5d 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,15 +1,15 @@ -from typing import cast -from typing_extensions import Unpack -from collections.abc import AsyncIterator -from mcp import ClientSession import contextlib -from mcp.shared.memory import create_client_server_memory_streams +from collections.abc import AsyncIterator +from typing import cast import pytest +from mcp import ClientSession +from mcp.shared.memory import create_client_server_memory_streams from pydantic import AnyUrl +from typing_extensions import Unpack from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport, ClientTransport, SessionKwargs +from fastmcp.client.transports import ClientTransport, FastMCPTransport, SessionKwargs from fastmcp.server.server import FastMCP @@ -164,15 +164,18 @@ async def test_client_connection(fastmcp_server): # After connection assert not client.is_connected() + async def test_client_nested_context_manager(fastmcp_server): """Test that the client connects and disconnects once in nested context manager.""" + class MockTransport(ClientTransport): def __init__(self): self._connected = False @contextlib.asynccontextmanager async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs], + self, + **session_kwargs: Unpack[SessionKwargs], ) -> AsyncIterator[ClientSession]: assert not self._connected, "Transport is connected multiple times" self._connected = True @@ -200,6 +203,7 @@ async def test_client_nested_context_manager(fastmcp_server): # After connection assert not client.is_connected() + async def test_resource_template(fastmcp_server): """Test using a resource template with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) From fb21ccb50ea6a322365e39d6bd882c6e74c2909e Mon Sep 17 00:00:00 2001 From: yihuang Date: Wed, 23 Apr 2025 10:37:40 +0800 Subject: [PATCH 13/17] feat: use sys.executable instead of python on PATH --- src/fastmcp/client/transports.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fastmcp/client/transports.py b/src/fastmcp/client/transports.py index 68544512a..210286bf2 100644 --- a/src/fastmcp/client/transports.py +++ b/src/fastmcp/client/transports.py @@ -3,6 +3,7 @@ import contextlib import datetime import os import shutil +import sys from collections.abc import AsyncIterator from pathlib import Path from typing import ( @@ -185,7 +186,7 @@ class PythonStdioTransport(StdioTransport): args: list[str] | None = None, env: dict[str, str] | None = None, cwd: str | None = None, - python_cmd: str = "python", + python_cmd: str = sys.executable, ): """ Initialize a Python transport. From 2cc577168b8619584f31f9a9c2fef9c9ac118897 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:12:00 -0400 Subject: [PATCH 14/17] Ensure contrib is importable --- src/{ => fastmcp}/contrib/README.md | 12 +++++++++++- src/{ => fastmcp}/contrib/bulk_tool_caller/README.md | 0 .../contrib/bulk_tool_caller/__init__.py | 0 .../contrib/bulk_tool_caller/bulk_tool_caller.py | 6 +++++- .../contrib/bulk_tool_caller/example.py | 2 +- src/{ => fastmcp}/contrib/mcp_mixin/README.md | 2 +- src/{ => fastmcp}/contrib/mcp_mixin/__init__.py | 0 src/{ => fastmcp}/contrib/mcp_mixin/example.py | 4 ++-- src/{ => fastmcp}/contrib/mcp_mixin/mcp_mixin.py | 0 tests/contrib/test_bulk_tool_caller.py | 4 ++-- tests/contrib/test_mcp_mixin.py | 6 +++--- 11 files changed, 25 insertions(+), 11 deletions(-) rename src/{ => fastmcp}/contrib/README.md (58%) rename src/{ => fastmcp}/contrib/bulk_tool_caller/README.md (100%) rename src/{ => fastmcp}/contrib/bulk_tool_caller/__init__.py (100%) rename src/{ => fastmcp}/contrib/bulk_tool_caller/bulk_tool_caller.py (97%) rename src/{ => fastmcp}/contrib/bulk_tool_caller/example.py (81%) rename src/{ => fastmcp}/contrib/mcp_mixin/README.md (95%) rename src/{ => fastmcp}/contrib/mcp_mixin/__init__.py (100%) rename src/{ => fastmcp}/contrib/mcp_mixin/example.py (96%) rename src/{ => fastmcp}/contrib/mcp_mixin/mcp_mixin.py (100%) diff --git a/src/contrib/README.md b/src/fastmcp/contrib/README.md similarity index 58% rename from src/contrib/README.md rename to src/fastmcp/contrib/README.md index 3df31bf9c..7b1dbb550 100644 --- a/src/contrib/README.md +++ b/src/fastmcp/contrib/README.md @@ -6,4 +6,14 @@ This directory holds community-contributed modules for FastMCP. These modules ex * Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. * Changes to the core FastMCP library might break modules in `contrib` without explicit warnings in the main changelog. -Use these modules at your own discretion. Contributions are welcome, but please include tests and documentation. \ No newline at end of file +Use these modules at your own discretion. Contributions are welcome, but please include tests and documentation. + +## Usage + +To use a contrib module, import it from the `fastmcp.contrib` package. + +```python +from fastmcp.contrib import my_module +``` + +Note that the contrib modules may have different dependencies than the core library, which can be noted in their respective README's or even separate requirements / dependency files. \ No newline at end of file diff --git a/src/contrib/bulk_tool_caller/README.md b/src/fastmcp/contrib/bulk_tool_caller/README.md similarity index 100% rename from src/contrib/bulk_tool_caller/README.md rename to src/fastmcp/contrib/bulk_tool_caller/README.md diff --git a/src/contrib/bulk_tool_caller/__init__.py b/src/fastmcp/contrib/bulk_tool_caller/__init__.py similarity index 100% rename from src/contrib/bulk_tool_caller/__init__.py rename to src/fastmcp/contrib/bulk_tool_caller/__init__.py diff --git a/src/contrib/bulk_tool_caller/bulk_tool_caller.py b/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py similarity index 97% rename from src/contrib/bulk_tool_caller/bulk_tool_caller.py rename to src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py index aa02c5e61..365dd95b7 100644 --- a/src/contrib/bulk_tool_caller/bulk_tool_caller.py +++ b/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py @@ -3,10 +3,14 @@ from typing import Any from mcp.types import CallToolResult from pydantic import BaseModel, Field -from contrib.mcp_mixin.mcp_mixin import _DEFAULT_SEPARATOR_TOOL, MCPMixin, mcp_tool from fastmcp import FastMCP from fastmcp.client import Client from fastmcp.client.transports import FastMCPTransport +from fastmcp.contrib.mcp_mixin.mcp_mixin import ( + _DEFAULT_SEPARATOR_TOOL, + MCPMixin, + mcp_tool, +) class CallToolRequest(BaseModel): diff --git a/src/contrib/bulk_tool_caller/example.py b/src/fastmcp/contrib/bulk_tool_caller/example.py similarity index 81% rename from src/contrib/bulk_tool_caller/example.py rename to src/fastmcp/contrib/bulk_tool_caller/example.py index 76170c197..85139feda 100644 --- a/src/contrib/bulk_tool_caller/example.py +++ b/src/fastmcp/contrib/bulk_tool_caller/example.py @@ -1,7 +1,7 @@ """Sample code for FastMCP using MCPMixin.""" -from contrib.bulk_tool_caller import BulkToolCaller from fastmcp import FastMCP +from fastmcp.contrib.bulk_tool_caller import BulkToolCaller mcp = FastMCP() diff --git a/src/contrib/mcp_mixin/README.md b/src/fastmcp/contrib/mcp_mixin/README.md similarity index 95% rename from src/contrib/mcp_mixin/README.md rename to src/fastmcp/contrib/mcp_mixin/README.md index 3acb66190..6dce9c9e3 100644 --- a/src/contrib/mcp_mixin/README.md +++ b/src/fastmcp/contrib/mcp_mixin/README.md @@ -10,7 +10,7 @@ Inherit from `MCPMixin` and use the decorators on the methods you want to regist ```python from fastmcp import FastMCP -from contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource +from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource class MyComponent(MCPMixin): @mcp_tool(name="my_tool", description="Does something cool.") diff --git a/src/contrib/mcp_mixin/__init__.py b/src/fastmcp/contrib/mcp_mixin/__init__.py similarity index 100% rename from src/contrib/mcp_mixin/__init__.py rename to src/fastmcp/contrib/mcp_mixin/__init__.py diff --git a/src/contrib/mcp_mixin/example.py b/src/fastmcp/contrib/mcp_mixin/example.py similarity index 96% rename from src/contrib/mcp_mixin/example.py rename to src/fastmcp/contrib/mcp_mixin/example.py index 5e996632e..dcdc4c272 100644 --- a/src/contrib/mcp_mixin/example.py +++ b/src/fastmcp/contrib/mcp_mixin/example.py @@ -2,13 +2,13 @@ import asyncio -from contrib.mcp_mixin import ( +from fastmcp import FastMCP +from fastmcp.contrib.mcp_mixin import ( MCPMixin, mcp_prompt, mcp_resource, mcp_tool, ) -from fastmcp import FastMCP mcp = FastMCP() diff --git a/src/contrib/mcp_mixin/mcp_mixin.py b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py similarity index 100% rename from src/contrib/mcp_mixin/mcp_mixin.py rename to src/fastmcp/contrib/mcp_mixin/mcp_mixin.py diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index 5e5519852..348eb0ef7 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -3,12 +3,12 @@ from typing import Any import pytest from mcp.types import EmbeddedResource, ImageContent, TextContent -from contrib.bulk_tool_caller.bulk_tool_caller import ( +from fastmcp import FastMCP +from fastmcp.contrib.bulk_tool_caller.bulk_tool_caller import ( BulkToolCaller, CallToolRequest, CallToolRequestResult, ) -from fastmcp import FastMCP ContentType = TextContent | ImageContent | EmbeddedResource diff --git a/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py index b31919cff..a39b293e3 100644 --- a/tests/contrib/test_mcp_mixin.py +++ b/tests/contrib/test_mcp_mixin.py @@ -2,18 +2,18 @@ import pytest -from contrib.mcp_mixin import ( +from fastmcp import FastMCP +from fastmcp.contrib.mcp_mixin import ( MCPMixin, mcp_prompt, mcp_resource, mcp_tool, ) -from contrib.mcp_mixin.mcp_mixin import ( +from fastmcp.contrib.mcp_mixin.mcp_mixin import ( _DEFAULT_SEPARATOR_PROMPT, _DEFAULT_SEPARATOR_RESOURCE, _DEFAULT_SEPARATOR_TOOL, ) -from fastmcp import FastMCP class TestMCPMixin: From 7266daa753cd892f729710103c1df8e00b0e5035 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:12:14 -0400 Subject: [PATCH 15/17] Add docs --- docs/docs.json | 3 ++- docs/patterns/contrib.mdx | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 docs/patterns/contrib.mdx diff --git a/docs/docs.json b/docs/docs.json index 237dee35b..c70f80404 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -61,7 +61,8 @@ "patterns/composition", "patterns/decorating-methods", "patterns/openapi", - "patterns/fastapi" + "patterns/fastapi", + "patterns/contrib" ] }, { diff --git a/docs/patterns/contrib.mdx b/docs/patterns/contrib.mdx new file mode 100644 index 000000000..4ae3b3d11 --- /dev/null +++ b/docs/patterns/contrib.mdx @@ -0,0 +1,40 @@ +--- +title: "Contrib Modules" +description: "Community-contributed modules extending FastMCP" +icon: "cubes" +--- + + +FastMCP includes a `contrib` package that holds community-contributed modules. These modules extend FastMCP's functionality but aren't officially maintained by the core team. + +Contrib modules provide additional features, integrations, or patterns that complement the core FastMCP library. They offer a way for the community to share useful extensions while keeping the core library focused and maintainable. + +## Usage + +To use a contrib module, import it from the `fastmcp.contrib` package: + +```python +from fastmcp.contrib import my_module +``` + +## Important Considerations + +- **Stability**: Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. +- **Compatibility**: Changes to core FastMCP might break modules in `contrib` without explicit warnings in the main changelog. +- **Dependencies**: Contrib modules may have additional dependencies not required by the core library. These dependencies are typically documented in the module's README or separate requirements files. + +## Contributing + +We welcome contributions to the `contrib` package! If you have a module that extends FastMCP in a useful way, consider contributing it: + +1. Create a new directory in `src/fastmcp/contrib/` for your module +3. Add proper tests for your module in `tests/contrib/` +2. Include comprehensive documentation in a README.md file, including usage and examples, as well as any additional dependencies or installation instructions +5. Submit a pull request + +The ideal contrib module: +- Solves a specific use case or integration need +- Follows FastMCP coding standards +- Includes thorough documentation and examples +- Has comprehensive tests +- Specifies any additional dependencies From fee097f86df8fc22e6d649fe800566c867366e38 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:13:24 -0400 Subject: [PATCH 16/17] Update contrib.mdx --- docs/patterns/contrib.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/patterns/contrib.mdx b/docs/patterns/contrib.mdx index 4ae3b3d11..920b248bf 100644 --- a/docs/patterns/contrib.mdx +++ b/docs/patterns/contrib.mdx @@ -9,6 +9,8 @@ FastMCP includes a `contrib` package that holds community-contributed modules. T Contrib modules provide additional features, integrations, or patterns that complement the core FastMCP library. They offer a way for the community to share useful extensions while keeping the core library focused and maintainable. +The available modules can be viewed in the [contrib directory](https://github.com/jlowin/fastmcp/tree/main/src/contrib). + ## Usage To use a contrib module, import it from the `fastmcp.contrib` package: From 5ed101284a89be37e7780ab951bbafcf66937a23 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Wed, 23 Apr 2025 14:26:13 -0400 Subject: [PATCH 17/17] Remove mock transport --- tests/client/test_client.py | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 5165c2a5d..7bc0fe22e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -1,15 +1,10 @@ -import contextlib -from collections.abc import AsyncIterator from typing import cast import pytest -from mcp import ClientSession -from mcp.shared.memory import create_client_server_memory_streams from pydantic import AnyUrl -from typing_extensions import Unpack from fastmcp.client import Client -from fastmcp.client.transports import ClientTransport, FastMCPTransport, SessionKwargs +from fastmcp.client.transports import FastMCPTransport from fastmcp.server.server import FastMCP @@ -168,40 +163,31 @@ async def test_client_connection(fastmcp_server): async def test_client_nested_context_manager(fastmcp_server): """Test that the client connects and disconnects once in nested context manager.""" - class MockTransport(ClientTransport): - def __init__(self): - self._connected = False - - @contextlib.asynccontextmanager - async def connect_session( - self, - **session_kwargs: Unpack[SessionKwargs], - ) -> AsyncIterator[ClientSession]: - assert not self._connected, "Transport is connected multiple times" - self._connected = True - async with create_client_server_memory_streams() as ( - _, - server_streams, - ): - yield ClientSession(*server_streams) - - client = Client(transport=MockTransport()) + client = Client(fastmcp_server) # Before connection assert not client.is_connected() + assert client._session is None # During connection async with client: assert client.is_connected() + assert client._session is not None + session = client._session + # Re-use the same session async with client: assert client.is_connected() + assert client._session is session + # Re-use the same session async with client: assert client.is_connected() + assert client._session is session # After connection assert not client.is_connected() + assert client._session is None async def test_resource_template(fastmcp_server):