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..920b248bf --- /dev/null +++ b/docs/patterns/contrib.mdx @@ -0,0 +1,42 @@ +--- +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. + +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: + +```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 diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index c49472d2b..7fdd21899 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -45,7 +45,8 @@ class Client: ): self.transport = infer_transport(transport) self._session: ClientSession | None = None - self._session_cms: list[AbstractAsyncContextManager[ClientSession]] = [] + self._session_cm: AbstractAsyncContextManager[ClientSession] | None = None + self._nesting_counter: int = 0 self._session_kwargs: SessionKwargs = { "sampling_callback": None, @@ -85,29 +86,21 @@ class Client: return self._session is not None async def __aenter__(self): - if self.is_connected(): - # We're already connected, no need to add None to the session_cms list - return self + if self._nesting_counter == 0: + # create new session + self._session_cm = self.transport.connect_session(**self._session_kwargs) + self._session = await self._session_cm.__aenter__() - try: - 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 + self._nesting_counter += 1 + 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._nesting_counter -= 1 + + 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 - self._session_cms.pop() # --- MCP Client Methods --- async def ping(self) -> None: 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. 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/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py index e4c31e5c2..b3d9d8dce 100644 --- a/src/fastmcp/server/proxy.py +++ b/src/fastmcp/server/proxy.py @@ -3,7 +3,9 @@ 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, @@ -173,7 +175,14 @@ 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 = [] + else: + raise e + for tool in client_tools: tool_proxy = await ProxyTool.from_client(self.client, tool) tools[tool_proxy.name] = tool_proxy @@ -183,7 +192,14 @@ 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 = [] + else: + raise e + for resource in client_resources: resource_proxy = await ProxyResource.from_client(self.client, resource) resources[str(resource_proxy.uri)] = resource_proxy @@ -193,7 +209,14 @@ 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 = [] + else: + raise e + for template in client_templates: template_proxy = await ProxyTemplate.from_client(self.client, template) templates[template_proxy.uri_template] = template_proxy @@ -203,7 +226,14 @@ 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 = [] + else: + raise e + for prompt in client_prompts: prompt_proxy = await ProxyPrompt.from_client(self.client, prompt) prompts[prompt_proxy.name] = prompt_proxy return prompts diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 17c299e8c..7bc0fe22e 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -160,6 +160,36 @@ async def test_client_connection(fastmcp_server): 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.""" + + 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): """Test using a resource template with InMemoryClient.""" client = Client(transport=FastMCPTransport(fastmcp_server)) 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: