From 4f5a9214cbe78fa1039e993c6beab154b5cd8932 Mon Sep 17 00:00:00 2001 From: strawgate Date: Tue, 15 Apr 2025 22:56:51 -0500 Subject: [PATCH 1/6] McpRegisterable Example --- sample.py | 46 ++++++++ src/fastmcp/utilities/registerable.py | 160 ++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 sample.py create mode 100644 src/fastmcp/utilities/registerable.py diff --git a/sample.py b/sample.py new file mode 100644 index 000000000..4fa2ceabd --- /dev/null +++ b/sample.py @@ -0,0 +1,46 @@ +"""Sample code for FastMCP.""" + +from src.fastmcp import FastMCP +from src.fastmcp.utilities.registerable import ( + McpRegisterable, + mcp_prompt, + mcp_resource, + mcp_tool, +) + +mcp = FastMCP() + + +class Sample(McpRegisterable): + def __init__(self, name): + self.name = name + + @mcp_tool() + def first_tool(self): + """First tool description.""" + return f"Executed tool {self.name}." + + @mcp_resource(uri="test://test") + def first_resource(self): + """First resource description.""" + return f"Executed resource {self.name}." + + @mcp_prompt() + def first_prompt(self): + """First prompt description.""" + return f"here's a prompt! {self.name}." + + +first_sample = Sample("First") +second_sample = Sample("Second") + +first_sample.register_all(mcp_server=mcp, prefix="first") +second_sample.register_all(mcp_server=mcp, prefix="second") + + +def main(): + mcp.run("sse") + + +if __name__ == "__main__": + main() diff --git a/src/fastmcp/utilities/registerable.py b/src/fastmcp/utilities/registerable.py new file mode 100644 index 000000000..9cf19706c --- /dev/null +++ b/src/fastmcp/utilities/registerable.py @@ -0,0 +1,160 @@ +"""Provides a base class and decorators for easy registration of class methods with FastMCP.""" + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from ..server import FastMCP + +_MCP_REGISTRATION_TOOL_ATTR = "_mcp_tool_registration" +_MCP_REGISTRATION_RESOURCE_ATTR = "_mcp_resource_registration" +_MCP_REGISTRATION_PROMPT_ATTR = "_mcp_prompt_registration" + + +def mcp_tool( + name: str | None = None, + description: str | None = None, + tags: set[str] | None = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator to mark a method as an MCP tool for later registration.""" + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + call_args = { + "name": name or func.__name__, + "description": description, + "tags": tags, + } + call_args = {k: v for k, v in call_args.items() if v is not None} + setattr(func, _MCP_REGISTRATION_TOOL_ATTR, call_args) + return func + + return decorator + + +def mcp_resource( + uri: str, + *, + name: str | None = None, + description: str | None = None, + mime_type: str | None = None, + tags: set[str] | None = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator to mark a method as an MCP resource for later registration.""" + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + call_args = { + "uri": uri, + "name": name or func.__name__, + "description": description, + "mime_type": mime_type, + "tags": tags, + } + call_args = {k: v for k, v in call_args.items() if v is not None} + + setattr(func, _MCP_REGISTRATION_RESOURCE_ATTR, call_args) + + return func + + return decorator + + +def mcp_prompt( + name: str | None = None, + description: str | None = None, + tags: set[str] | None = None, +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + """Decorator to mark a method as an MCP prompt for later registration.""" + + def decorator(func: Callable[..., Any]) -> Callable[..., Any]: + call_args = { + "name": name or func.__name__, + "description": description, + "tags": tags, + } + + call_args = {k: v for k, v in call_args.items() if v is not None} + + setattr(func, _MCP_REGISTRATION_PROMPT_ATTR, call_args) + return func + + return decorator + + +class McpRegisterable: + """Base class for objects that can register tools, resources, and prompts + with a FastMCP server instance using decorators. + """ + + def _get_methods_to_register(self, registration_type: str): + """Retrieves all registration info for the specified type.""" + + return [ + ( + getattr(self, method_name), + getattr(getattr(self, method_name), registration_type).copy(), + ) + for method_name in dir(self) + if hasattr(getattr(self, method_name), registration_type) + ] + + def register_tools(self, mcp_server: "FastMCP", prefix: str | None = None) -> None: + """Registers all methods marked with @mcp_tool with the FastMCP server. + + Args: + mcp_server: The FastMCP server instance to register tools with. + """ + + for method, registration_info in self._get_methods_to_register( + _MCP_REGISTRATION_TOOL_ATTR + ): + if prefix: + registration_info["name"] = f"{prefix}_{registration_info['name']}" + + mcp_server.add_tool(fn=method, **registration_info) + + def register_resources( + self, mcp_server: "FastMCP", prefix: str | None = None + ) -> None: + """Registers all methods marked with @mcp_resource with the FastMCP server. + + Args: + mcp_server: The FastMCP server instance to register resources with. + """ + + for method, registration_info in self._get_methods_to_register( + _MCP_REGISTRATION_RESOURCE_ATTR + ): + if prefix: + registration_info["name"] = f"{prefix}_{registration_info['name']}" + registration_info["uri"] = f"{prefix}+{registration_info['uri']}" + + mcp_server.add_resource_fn(fn=method, **registration_info) + + def register_prompts( + self, mcp_server: "FastMCP", prefix: str | None = None + ) -> None: + """Registers all methods marked with @mcp_prompt with the FastMCP server. + + Args: + mcp_server: The FastMCP server instance to register prompts with. + """ + for method, registration_info in self._get_methods_to_register( + _MCP_REGISTRATION_PROMPT_ATTR + ): + if prefix: + registration_info["name"] = f"{prefix}_{registration_info['name']}" + + mcp_server.add_prompt(fn=method, **registration_info) + + def register_all( + self, + mcp_server: "FastMCP", + prefix: str | None = None, + tools_prefix: str | None = None, + resources_prefix: str | None = None, + prompts_prefix: str | None = None, + ) -> None: + """Registers all marked tools, resources, and prompts.""" + self.register_tools(mcp_server, prefix=tools_prefix or prefix) + self.register_resources(mcp_server, prefix=resources_prefix or prefix) + self.register_prompts(mcp_server, prefix=prompts_prefix or prefix) From 5de40ac300aa508499b37050bcd3fcc302a316aa Mon Sep 17 00:00:00 2001 From: strawgate Date: Wed, 16 Apr 2025 23:30:09 -0500 Subject: [PATCH 2/6] Updates based on PR Feedback --- src/contrib/README.md | 9 + src/contrib/mcp_mixin/README.md | 39 +++ sample.py => src/contrib/mcp_mixin/example.py | 16 +- .../mcp_mixin/mcp_mixin.py} | 100 +++++-- tests/contrib/__init__.py | 1 + tests/contrib/test_mcp_mixin.py | 243 ++++++++++++++++++ 6 files changed, 376 insertions(+), 32 deletions(-) create mode 100644 src/contrib/README.md create mode 100644 src/contrib/mcp_mixin/README.md rename sample.py => src/contrib/mcp_mixin/example.py (66%) rename src/{fastmcp/utilities/registerable.py => contrib/mcp_mixin/mcp_mixin.py} (51%) create mode 100644 tests/contrib/__init__.py create mode 100644 tests/contrib/test_mcp_mixin.py diff --git a/src/contrib/README.md b/src/contrib/README.md new file mode 100644 index 000000000..3df31bf9c --- /dev/null +++ b/src/contrib/README.md @@ -0,0 +1,9 @@ +# FastMCP Contrib Modules + +This directory holds community-contributed modules for FastMCP. These modules extend FastMCP's functionality but are not officially maintained by the core team. + +**Guarantees:** +* 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 diff --git a/src/contrib/mcp_mixin/README.md b/src/contrib/mcp_mixin/README.md new file mode 100644 index 000000000..4d1f4d387 --- /dev/null +++ b/src/contrib/mcp_mixin/README.md @@ -0,0 +1,39 @@ +# MCP Mixin + +This module provides the `MCPMixin` base class and associated decorators (`@mcp_tool`, `@mcp_resource`, `@mcp_prompt`). + +It allows developers to easily define classes whose methods can be registered as tools, resources, or prompts with a `FastMCP` server instance using the `register_all()`, `register_tools()`, `register_resources()`, or `register_prompts()` methods provided by the mixin. + +## Usage + +Inherit from `MCPMixin` and use the decorators on the methods you want to register. + +```python +from fastmcp import FastMCP +from contrib.mcp_mixin.mcp_mixin import MCPMixin, mcp_tool, mcp_resource + +class MyComponent(MCPMixin): + @mcp_tool(name="my_tool", description="Does something cool.") + def tool_method(self): + return "Tool executed!" + + @mcp_resource(uri="component://data") + def resource_method(self): + return {"data": "some data"} + +mcp_server = FastMCP() +component = MyComponent() + +# Register all decorated methods with a prefix +# Useful if you will have multiple instantiated objects of the same class +# and want to avoid name collisions. +component.register_all(mcp_server, prefix="my_comp") + +# Register without a prefix +# component.register_all(mcp_server) + +# Now 'my_comp_my_tool' tool and 'my_comp+component://data' resource are registered (if prefix used) +# Or 'my_tool' and 'component://data' are registered (if no prefix used) +``` + +The `prefix` argument in registration methods is optional. If omitted, methods are registered with their original decorated names/URIs. Individual separators (`tools_separator`, `resources_separator`, `prompts_separator`) can also be provided to `register_all` to change the separator for specific types. \ No newline at end of file diff --git a/sample.py b/src/contrib/mcp_mixin/example.py similarity index 66% rename from sample.py rename to src/contrib/mcp_mixin/example.py index 4fa2ceabd..2fdf59ebf 100644 --- a/sample.py +++ b/src/contrib/mcp_mixin/example.py @@ -1,17 +1,17 @@ -"""Sample code for FastMCP.""" +"""Sample code for FastMCP using MCPMixin.""" -from src.fastmcp import FastMCP -from src.fastmcp.utilities.registerable import ( - McpRegisterable, +from contrib.mcp_mixin.mcp_mixin import ( + MCPMixin, mcp_prompt, mcp_resource, mcp_tool, ) +from fastmcp import FastMCP mcp = FastMCP() -class Sample(McpRegisterable): +class Sample(MCPMixin): def __init__(self, name): self.name = name @@ -39,7 +39,11 @@ second_sample.register_all(mcp_server=mcp, prefix="second") def main(): - mcp.run("sse") + print("MCP Server running with registered components...") + print("Tools:", list(mcp.get_tools().keys())) + print("Resources:", list(mcp.get_resources().keys())) + print("Prompts:", [p.name for p in mcp.list_prompts()]) + mcp.run() if __name__ == "__main__": diff --git a/src/fastmcp/utilities/registerable.py b/src/contrib/mcp_mixin/mcp_mixin.py similarity index 51% rename from src/fastmcp/utilities/registerable.py rename to src/contrib/mcp_mixin/mcp_mixin.py index 9cf19706c..7a2f255df 100644 --- a/src/fastmcp/utilities/registerable.py +++ b/src/contrib/mcp_mixin/mcp_mixin.py @@ -1,15 +1,19 @@ -"""Provides a base class and decorators for easy registration of class methods with FastMCP.""" +"""Provides a base mixin class and decorators for easy registration of class methods with FastMCP.""" from collections.abc import Callable from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from ..server import FastMCP + from fastmcp.server import FastMCP _MCP_REGISTRATION_TOOL_ATTR = "_mcp_tool_registration" _MCP_REGISTRATION_RESOURCE_ATTR = "_mcp_resource_registration" _MCP_REGISTRATION_PROMPT_ATTR = "_mcp_prompt_registration" +_DEFAULT_SEPARATOR_TOOL = "_" +_DEFAULT_SEPARATOR_RESOURCE = "+" +_DEFAULT_SEPARATOR_PROMPT = "_" + def mcp_tool( name: str | None = None, @@ -80,81 +84,125 @@ def mcp_prompt( return decorator -class McpRegisterable: - """Base class for objects that can register tools, resources, and prompts +class MCPMixin: + """Base mixin class for objects that can register tools, resources, and prompts with a FastMCP server instance using decorators. + + This mixin provides methods like `register_all`, `register_tools`, etc., + which iterate over the methods of the inheriting class, find methods + decorated with `@mcp_tool`, `@mcp_resource`, or `@mcp_prompt`, and + register them with the provided FastMCP server instance. """ def _get_methods_to_register(self, registration_type: str): - """Retrieves all registration info for the specified type.""" - + """Retrieves all methods marked for a specific registration type.""" return [ ( getattr(self, method_name), getattr(getattr(self, method_name), registration_type).copy(), ) for method_name in dir(self) - if hasattr(getattr(self, method_name), registration_type) + if callable(getattr(self, method_name)) + and hasattr(getattr(self, method_name), registration_type) ] - def register_tools(self, mcp_server: "FastMCP", prefix: str | None = None) -> None: + def register_tools( + self, + mcp_server: "FastMCP", + prefix: str | None = None, + separator: str = _DEFAULT_SEPARATOR_TOOL, + ) -> None: """Registers all methods marked with @mcp_tool with the FastMCP server. Args: mcp_server: The FastMCP server instance to register tools with. + prefix: Optional prefix to prepend to tool names. If provided, the + final name will be f"{prefix}{separator}{original_name}". + separator: The separator string used between prefix and original name. + Defaults to '_'. """ - for method, registration_info in self._get_methods_to_register( _MCP_REGISTRATION_TOOL_ATTR ): if prefix: - registration_info["name"] = f"{prefix}_{registration_info['name']}" - + registration_info["name"] = ( + f"{prefix}{separator}{registration_info['name']}" + ) mcp_server.add_tool(fn=method, **registration_info) def register_resources( - self, mcp_server: "FastMCP", prefix: str | None = None + self, + mcp_server: "FastMCP", + prefix: str | None = None, + separator: str = _DEFAULT_SEPARATOR_RESOURCE, ) -> None: """Registers all methods marked with @mcp_resource with the FastMCP server. Args: mcp_server: The FastMCP server instance to register resources with. + prefix: Optional prefix to prepend to resource names and URIs. If provided, + the final name will be f"{prefix}{separator}{original_name}" and the + final URI will be f"{prefix}{separator}{original_uri}". + separator: The separator string used between prefix and original name/URI. + Defaults to '+'. """ - for method, registration_info in self._get_methods_to_register( _MCP_REGISTRATION_RESOURCE_ATTR ): if prefix: - registration_info["name"] = f"{prefix}_{registration_info['name']}" - registration_info["uri"] = f"{prefix}+{registration_info['uri']}" - + registration_info["name"] = ( + f"{prefix}{separator}{registration_info['name']}" + ) + registration_info["uri"] = ( + f"{prefix}{separator}{registration_info['uri']}" + ) mcp_server.add_resource_fn(fn=method, **registration_info) def register_prompts( - self, mcp_server: "FastMCP", prefix: str | None = None + self, + mcp_server: "FastMCP", + prefix: str | None = None, + separator: str = _DEFAULT_SEPARATOR_PROMPT, ) -> None: """Registers all methods marked with @mcp_prompt with the FastMCP server. Args: mcp_server: The FastMCP server instance to register prompts with. + prefix: Optional prefix to prepend to prompt names. If provided, the + final name will be f"{prefix}{separator}{original_name}". + separator: The separator string used between prefix and original name. + Defaults to '_'. """ for method, registration_info in self._get_methods_to_register( _MCP_REGISTRATION_PROMPT_ATTR ): if prefix: - registration_info["name"] = f"{prefix}_{registration_info['name']}" - + registration_info["name"] = ( + f"{prefix}{separator}{registration_info['name']}" + ) mcp_server.add_prompt(fn=method, **registration_info) def register_all( self, mcp_server: "FastMCP", prefix: str | None = None, - tools_prefix: str | None = None, - resources_prefix: str | None = None, - prompts_prefix: str | None = None, + tool_separator: str = _DEFAULT_SEPARATOR_TOOL, + resource_separator: str = _DEFAULT_SEPARATOR_RESOURCE, + prompt_separator: str = _DEFAULT_SEPARATOR_PROMPT, ) -> None: - """Registers all marked tools, resources, and prompts.""" - self.register_tools(mcp_server, prefix=tools_prefix or prefix) - self.register_resources(mcp_server, prefix=resources_prefix or prefix) - self.register_prompts(mcp_server, prefix=prompts_prefix or prefix) + """Registers all marked tools, resources, and prompts with the server. + + This method calls `register_tools`, `register_resources`, and `register_prompts` + internally, passing the provided prefix and separators. + + Args: + mcp_server: The FastMCP server instance to register with. + prefix: Optional prefix applied to all registered items unless overridden + by a specific separator argument. + tool_separator: Separator for tool names (defaults to '_'). + resource_separator: Separator for resource names/URIs (defaults to '+'). + prompt_separator: Separator for prompt names (defaults to '_'). + """ + self.register_tools(mcp_server, prefix=prefix, separator=tool_separator) + self.register_resources(mcp_server, prefix=prefix, separator=resource_separator) + self.register_prompts(mcp_server, prefix=prefix, separator=prompt_separator) diff --git a/tests/contrib/__init__.py b/tests/contrib/__init__.py new file mode 100644 index 000000000..32113d184 --- /dev/null +++ b/tests/contrib/__init__.py @@ -0,0 +1 @@ +# This file makes Python treat the directory as a package. diff --git a/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py new file mode 100644 index 000000000..f6f2aac66 --- /dev/null +++ b/tests/contrib/test_mcp_mixin.py @@ -0,0 +1,243 @@ +"""Tests for the MCPMixin class.""" + +import pytest + +from contrib.mcp_mixin.mcp_mixin import ( + _DEFAULT_SEPARATOR_PROMPT, + _DEFAULT_SEPARATOR_RESOURCE, + _DEFAULT_SEPARATOR_TOOL, + MCPMixin, + mcp_prompt, + mcp_resource, + mcp_tool, +) +from fastmcp import FastMCP + + +class TestMCPMixin: + """Test suite for MCPMixin functionality.""" + + def test_initialization(self): + """Test that a class inheriting MCPMixin can be initialized.""" + + class MyMixin(MCPMixin): + pass + + instance = MyMixin() + assert instance is not None + + # --- Tool Registration Tests --- + @pytest.mark.parametrize( + "prefix, separator, expected_key, unexpected_key", + [ + ( + None, + _DEFAULT_SEPARATOR_TOOL, + "sample_tool", + f"None{_DEFAULT_SEPARATOR_TOOL}sample_tool", + ), + ( + "pref", + _DEFAULT_SEPARATOR_TOOL, + f"pref{_DEFAULT_SEPARATOR_TOOL}sample_tool", + "sample_tool", + ), + ( + "pref", + "-", + "pref-sample_tool", + f"pref{_DEFAULT_SEPARATOR_TOOL}sample_tool", + ), + ], + ids=["No prefix", "Default separator", "Custom separator"], + ) + def test_tool_registration(self, prefix, separator, expected_key, unexpected_key): + """Test tool registration with prefix and separator variations.""" + mcp = FastMCP() + + class MyToolMixin(MCPMixin): + @mcp_tool() + def sample_tool(self): + pass + + instance = MyToolMixin() + instance.register_tools(mcp, prefix=prefix, separator=separator) + + registered_tools = mcp.get_tools() + assert expected_key in registered_tools + assert unexpected_key not in registered_tools + + @pytest.mark.parametrize( + "prefix, separator, expected_uri_key, expected_name, unexpected_uri_key", + [ + ( + None, + _DEFAULT_SEPARATOR_RESOURCE, + "test://resource", + "sample_resource", + f"None{_DEFAULT_SEPARATOR_RESOURCE}test://resource", + ), + ( + "pref", + _DEFAULT_SEPARATOR_RESOURCE, + f"pref{_DEFAULT_SEPARATOR_RESOURCE}test://resource", + f"pref{_DEFAULT_SEPARATOR_RESOURCE}sample_resource", + "test://resource", + ), + ( + "pref", + "fff", + "prefffftest://resource", + "preffffsample_resource", + f"pref{_DEFAULT_SEPARATOR_RESOURCE}test://resource", + ), + ], + ids=["No prefix", "Default separator", "Custom separator"], + ) + def test_resource_registration( + self, prefix, separator, expected_uri_key, expected_name, unexpected_uri_key + ): + """Test resource registration with prefix and separator variations.""" + mcp = FastMCP() + + class MyResourceMixin(MCPMixin): + @mcp_resource(uri="test://resource") + def sample_resource(self): + pass + + instance = MyResourceMixin() + instance.register_resources(mcp, prefix=prefix, separator=separator) + + registered_resources = mcp.get_resources() + assert expected_uri_key in registered_resources + assert registered_resources[expected_uri_key].name == expected_name + assert unexpected_uri_key not in registered_resources + + @pytest.mark.parametrize( + "prefix, separator, expected_name, unexpected_name", + [ + ( + None, + _DEFAULT_SEPARATOR_PROMPT, + "sample_prompt", + f"None{_DEFAULT_SEPARATOR_PROMPT}sample_prompt", + ), + ( + "pref", + _DEFAULT_SEPARATOR_PROMPT, + f"pref{_DEFAULT_SEPARATOR_PROMPT}sample_prompt", + "sample_prompt", + ), + ( + "pref", + ":", + "pref:sample_prompt", + f"pref{_DEFAULT_SEPARATOR_PROMPT}sample_prompt", + ), + ], + ids = ["No prefix", "Default separator", "Custom separator"] + ) + def test_prompt_registration( + self, prefix, separator, expected_name, unexpected_name + ): + """Test prompt registration with prefix and separator variations.""" + mcp = FastMCP() + + class MyPromptMixin(MCPMixin): + @mcp_prompt() + def sample_prompt(self): + pass + + instance = MyPromptMixin() + instance.register_prompts(mcp, prefix=prefix, separator=separator) + + registered_prompt_names = {p.name for p in mcp.list_prompts()} + assert expected_name in registered_prompt_names + assert unexpected_name not in registered_prompt_names + + def test_register_all_no_prefix(self): + """Test register_all method registers all types without a prefix.""" + mcp = FastMCP() + + class MyFullMixin(MCPMixin): + @mcp_tool() + def tool_all(self): + pass + + @mcp_resource(uri="res://all") + def resource_all(self): + pass + + @mcp_prompt() + def prompt_all(self): + pass + + instance = MyFullMixin() + instance.register_all(mcp) + + assert "tool_all" in mcp.get_tools() + assert "res://all" in mcp.get_resources() + assert "prompt_all" in {p.name for p in mcp.list_prompts()} + + def test_register_all_with_prefix_default_separators(self): + """Test register_all method registers all types with a prefix and default separators.""" + mcp = FastMCP() + + class MyFullMixinPrefixed(MCPMixin): + @mcp_tool() + def tool_all_p(self): + pass + + @mcp_resource(uri="res://all_p") + def resource_all_p(self): + pass + + @mcp_prompt() + def prompt_all_p(self): + pass + + instance = MyFullMixinPrefixed() + instance.register_all(mcp, prefix="all") + + assert f"all{_DEFAULT_SEPARATOR_TOOL}tool_all_p" in mcp.get_tools() + assert f"all{_DEFAULT_SEPARATOR_RESOURCE}res://all_p" in mcp.get_resources() + assert f"all{_DEFAULT_SEPARATOR_PROMPT}prompt_all_p" in { + p.name for p in mcp.list_prompts() + } + + def test_register_all_with_prefix_custom_separators(self): + """Test register_all method registers all types with a prefix and custom separators.""" + mcp = FastMCP() + + class MyFullMixinCustomSep(MCPMixin): + @mcp_tool() + def tool_cust(self): + pass + + @mcp_resource(uri="res://cust") + def resource_cust(self): + pass + + @mcp_prompt() + def prompt_cust(self): + pass + + instance = MyFullMixinCustomSep() + instance.register_all( + mcp, + prefix="cust", + tool_separator="-", + resource_separator="::", + prompt_separator=".", + ) + + assert "cust-tool_cust" in mcp.get_tools() + assert "cust::res://cust" in mcp.get_resources() + assert "cust.prompt_cust" in {p.name for p in mcp.list_prompts()} + + # Check default separators weren't used + assert f"cust{_DEFAULT_SEPARATOR_TOOL}tool_cust" not in mcp.get_tools() + assert f"cust{_DEFAULT_SEPARATOR_RESOURCE}res://cust" not in mcp.get_resources() + assert f"cust{_DEFAULT_SEPARATOR_PROMPT}prompt_cust" not in { + p.name for p in mcp.list_prompts() + } From 81cc7a62256456cc1a1d964289a1ebbba4f31b2f Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:44:43 -0400 Subject: [PATCH 3/6] Exclude contrib dirs from pyright --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d7875403e..76f706ac9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,4 @@ repos: hooks: - id: pyright-pretty files: ^src/|^tests/ - exclude: ^examples/ + exclude: ^examples/|^src/contrib/|^tests/contrib/ From 16dd2ee252c0187153b01559bcd165dda983cbf5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:44:48 -0400 Subject: [PATCH 4/6] Fix static checks --- tests/contrib/test_mcp_mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py index f6f2aac66..b119229d6 100644 --- a/tests/contrib/test_mcp_mixin.py +++ b/tests/contrib/test_mcp_mixin.py @@ -135,7 +135,7 @@ class TestMCPMixin: f"pref{_DEFAULT_SEPARATOR_PROMPT}sample_prompt", ), ], - ids = ["No prefix", "Default separator", "Custom separator"] + ids=["No prefix", "Default separator", "Custom separator"], ) def test_prompt_registration( self, prefix, separator, expected_name, unexpected_name From 7a9af6219977a5cfe1adfdf234b6a7bd201558e5 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:46:45 -0400 Subject: [PATCH 5/6] run tests on forks --- .github/workflows/run-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 05e8bcc46..f65e20a0d 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -60,4 +60,3 @@ jobs: - name: Run tests run: uv run pytest -vv - if: ${{ !(github.event.pull_request.head.repo.fork) }} From aba942b9e99f2f606728c9c837e05606117eeb7e Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Fri, 18 Apr 2025 08:52:37 -0400 Subject: [PATCH 6/6] Do run pyright; update tests for coros --- .pre-commit-config.yaml | 1 - src/contrib/mcp_mixin/example.py | 14 ++++--- tests/contrib/test_mcp_mixin.py | 64 ++++++++++++++++++-------------- 3 files changed, 45 insertions(+), 34 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 76f706ac9..d7f921d0f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -27,4 +27,3 @@ repos: hooks: - id: pyright-pretty files: ^src/|^tests/ - exclude: ^examples/|^src/contrib/|^tests/contrib/ diff --git a/src/contrib/mcp_mixin/example.py b/src/contrib/mcp_mixin/example.py index 2fdf59ebf..f3e8aeffc 100644 --- a/src/contrib/mcp_mixin/example.py +++ b/src/contrib/mcp_mixin/example.py @@ -1,5 +1,7 @@ """Sample code for FastMCP using MCPMixin.""" +import asyncio + from contrib.mcp_mixin.mcp_mixin import ( MCPMixin, mcp_prompt, @@ -38,13 +40,13 @@ first_sample.register_all(mcp_server=mcp, prefix="first") second_sample.register_all(mcp_server=mcp, prefix="second") -def main(): +async def list_components(): print("MCP Server running with registered components...") - print("Tools:", list(mcp.get_tools().keys())) - print("Resources:", list(mcp.get_resources().keys())) - print("Prompts:", [p.name for p in mcp.list_prompts()]) - mcp.run() + print("Tools:", list(await mcp.get_tools())) + print("Resources:", list(await mcp.get_resources())) + print("Prompts:", list(await mcp.get_prompts())) if __name__ == "__main__": - main() + asyncio.run(list_components()) + mcp.run() diff --git a/tests/contrib/test_mcp_mixin.py b/tests/contrib/test_mcp_mixin.py index b119229d6..6f69f545d 100644 --- a/tests/contrib/test_mcp_mixin.py +++ b/tests/contrib/test_mcp_mixin.py @@ -51,7 +51,9 @@ class TestMCPMixin: ], ids=["No prefix", "Default separator", "Custom separator"], ) - def test_tool_registration(self, prefix, separator, expected_key, unexpected_key): + async def test_tool_registration( + self, prefix, separator, expected_key, unexpected_key + ): """Test tool registration with prefix and separator variations.""" mcp = FastMCP() @@ -63,7 +65,7 @@ class TestMCPMixin: instance = MyToolMixin() instance.register_tools(mcp, prefix=prefix, separator=separator) - registered_tools = mcp.get_tools() + registered_tools = await mcp.get_tools() assert expected_key in registered_tools assert unexpected_key not in registered_tools @@ -94,7 +96,7 @@ class TestMCPMixin: ], ids=["No prefix", "Default separator", "Custom separator"], ) - def test_resource_registration( + async def test_resource_registration( self, prefix, separator, expected_uri_key, expected_name, unexpected_uri_key ): """Test resource registration with prefix and separator variations.""" @@ -108,7 +110,7 @@ class TestMCPMixin: instance = MyResourceMixin() instance.register_resources(mcp, prefix=prefix, separator=separator) - registered_resources = mcp.get_resources() + registered_resources = await mcp.get_resources() assert expected_uri_key in registered_resources assert registered_resources[expected_uri_key].name == expected_name assert unexpected_uri_key not in registered_resources @@ -137,7 +139,7 @@ class TestMCPMixin: ], ids=["No prefix", "Default separator", "Custom separator"], ) - def test_prompt_registration( + async def test_prompt_registration( self, prefix, separator, expected_name, unexpected_name ): """Test prompt registration with prefix and separator variations.""" @@ -151,11 +153,11 @@ class TestMCPMixin: instance = MyPromptMixin() instance.register_prompts(mcp, prefix=prefix, separator=separator) - registered_prompt_names = {p.name for p in mcp.list_prompts()} - assert expected_name in registered_prompt_names - assert unexpected_name not in registered_prompt_names + prompts = await mcp.get_prompts() + assert expected_name in prompts + assert unexpected_name not in prompts - def test_register_all_no_prefix(self): + async def test_register_all_no_prefix(self): """Test register_all method registers all types without a prefix.""" mcp = FastMCP() @@ -175,11 +177,15 @@ class TestMCPMixin: instance = MyFullMixin() instance.register_all(mcp) - assert "tool_all" in mcp.get_tools() - assert "res://all" in mcp.get_resources() - assert "prompt_all" in {p.name for p in mcp.list_prompts()} + tools = await mcp.get_tools() + resources = await mcp.get_resources() + prompts = await mcp.get_prompts() - def test_register_all_with_prefix_default_separators(self): + assert "tool_all" in tools + assert "res://all" in resources + assert "prompt_all" in prompts + + async def test_register_all_with_prefix_default_separators(self): """Test register_all method registers all types with a prefix and default separators.""" mcp = FastMCP() @@ -199,13 +205,15 @@ class TestMCPMixin: instance = MyFullMixinPrefixed() instance.register_all(mcp, prefix="all") - assert f"all{_DEFAULT_SEPARATOR_TOOL}tool_all_p" in mcp.get_tools() - assert f"all{_DEFAULT_SEPARATOR_RESOURCE}res://all_p" in mcp.get_resources() - assert f"all{_DEFAULT_SEPARATOR_PROMPT}prompt_all_p" in { - p.name for p in mcp.list_prompts() - } + tools = await mcp.get_tools() + resources = await mcp.get_resources() + prompts = await mcp.get_prompts() - def test_register_all_with_prefix_custom_separators(self): + assert f"all{_DEFAULT_SEPARATOR_TOOL}tool_all_p" in tools + assert f"all{_DEFAULT_SEPARATOR_RESOURCE}res://all_p" in resources + assert f"all{_DEFAULT_SEPARATOR_PROMPT}prompt_all_p" in prompts + + async def test_register_all_with_prefix_custom_separators(self): """Test register_all method registers all types with a prefix and custom separators.""" mcp = FastMCP() @@ -231,13 +239,15 @@ class TestMCPMixin: prompt_separator=".", ) - assert "cust-tool_cust" in mcp.get_tools() - assert "cust::res://cust" in mcp.get_resources() - assert "cust.prompt_cust" in {p.name for p in mcp.list_prompts()} + tools = await mcp.get_tools() + resources = await mcp.get_resources() + prompts = await mcp.get_prompts() + + assert "cust-tool_cust" in tools + assert "cust::res://cust" in resources + assert "cust.prompt_cust" in prompts # Check default separators weren't used - assert f"cust{_DEFAULT_SEPARATOR_TOOL}tool_cust" not in mcp.get_tools() - assert f"cust{_DEFAULT_SEPARATOR_RESOURCE}res://cust" not in mcp.get_resources() - assert f"cust{_DEFAULT_SEPARATOR_PROMPT}prompt_cust" not in { - p.name for p in mcp.list_prompts() - } + assert f"cust{_DEFAULT_SEPARATOR_TOOL}tool_cust" not in tools + assert f"cust{_DEFAULT_SEPARATOR_RESOURCE}res://cust" not in resources + assert f"cust{_DEFAULT_SEPARATOR_PROMPT}prompt_cust" not in prompts