From fe4f31c2c72767e1fec9084b346de8a52fa85c65 Mon Sep 17 00:00:00 2001 From: William Easton Date: Sun, 7 Sep 2025 10:11:44 -0500 Subject: [PATCH] Enable more type checking rules (#1775) --- pyproject.toml | 2 - src/fastmcp/client/client.py | 10 +- src/fastmcp/client/elicitation.py | 7 +- src/fastmcp/contrib/mcp_mixin/mcp_mixin.py | 47 ++++- .../utilities/openapi/director.py | 9 +- src/fastmcp/prompts/prompt.py | 18 +- src/fastmcp/resources/resource.py | 25 +-- src/fastmcp/resources/template.py | 22 ++- src/fastmcp/server/dependencies.py | 23 ++- src/fastmcp/server/server.py | 2 + src/fastmcp/tools/tool.py | 26 +-- src/fastmcp/utilities/types.py | 14 +- tests/cli/test_cli.py | 5 +- tests/cli/test_cursor.py | 1 + .../cli/test_mcp_server_config_integration.py | 3 + tests/cli/test_project_prepare.py | 3 + tests/cli/test_run.py | 5 + tests/cli/test_run_with_uv.py | 8 + tests/client/test_elicitation.py | 16 +- tests/client/test_streamable_http.py | 3 +- tests/conftest.py | 7 + tests/contrib/test_bulk_tool_caller.py | 180 ++++++++++++------ .../utilities/openapi/test_models.py | 23 ++- tests/prompts/test_prompt_manager.py | 7 +- tests/resources/test_resource_template.py | 4 +- tests/server/auth/providers/test_workos.py | 2 + tests/server/auth/test_jwt_provider.py | 5 + tests/server/auth/test_oauth_proxy.py | 1 + .../server/middleware/test_error_handling.py | 1 + tests/server/middleware/test_logging.py | 5 +- tests/server/openapi/conftest.py | 2 +- tests/server/proxy/test_proxy_client.py | 5 +- tests/server/test_import_server.py | 5 +- tests/tools/test_tool_manager.py | 11 +- tests/utilities/test_json_schema_type.py | 28 +-- uv.lock | 40 ++-- 36 files changed, 401 insertions(+), 174 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 189ad872c..4b7942dc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,11 +129,9 @@ python-version = "3.10" # Rules with too many errors to fix right now (40+ each) no-matching-overload = "ignore" # 126 errors unknown-argument = "ignore" # 61 errors -unresolved-attribute = "ignore" # 60 errors # Rules with moderate errors that need more investigation call-non-callable = "ignore" # 7 errors -missing-argument = "ignore" # 23 errors [tool.ruff.lint] extend-select = ["I", "UP"] diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py index 2592873d7..5eb0e0ede 100644 --- a/src/fastmcp/client/client.py +++ b/src/fastmcp/client/client.py @@ -505,7 +505,7 @@ class Client(Generic[ClientTransportT]): ) -> None: """Send a cancellation notification for an in-progress request.""" notification = mcp.types.ClientNotification( - mcp.types.CancelledNotification( + root=mcp.types.CancelledNotification( method="notifications/cancelled", params=mcp.types.CancelledNotificationParams( requestId=request_id, @@ -743,13 +743,13 @@ class Client(Generic[ClientTransportT]): async def complete_mcp( self, - ref: mcp.types.ResourceReference | mcp.types.PromptReference, + ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], ) -> mcp.types.CompleteResult: """Send a completion request and return the complete MCP protocol result. Args: - ref (mcp.types.ResourceReference | mcp.types.PromptReference): The reference to complete. + ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. argument (dict[str, str]): Arguments to pass to the completion request. Returns: @@ -766,13 +766,13 @@ class Client(Generic[ClientTransportT]): async def complete( self, - ref: mcp.types.ResourceReference | mcp.types.PromptReference, + ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, argument: dict[str, str], ) -> mcp.types.Completion: """Send a completion request to the server. Args: - ref (mcp.types.ResourceReference | mcp.types.PromptReference): The reference to complete. + ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. argument (dict[str, str]): Arguments to pass to the completion request. Returns: diff --git a/src/fastmcp/client/elicitation.py b/src/fastmcp/client/elicitation.py index c34885bdd..45855e276 100644 --- a/src/fastmcp/client/elicitation.py +++ b/src/fastmcp/client/elicitation.py @@ -59,7 +59,12 @@ def create_elicitation_callback( "Elicitation responses must be serializable as a JSON object (dict). Received: " f"{result.content!r}" ) - return MCPElicitResult(**result.model_dump() | {"content": content}) + return MCPElicitResult( + _meta=result.meta, + action=result.action, + content=content, + ) + except Exception as e: return mcp.types.ErrorData( code=mcp.types.INTERNAL_ERROR, diff --git a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py index 6b8cf62f7..8e11e6342 100644 --- a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py +++ b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py @@ -8,6 +8,7 @@ from mcp.types import ToolAnnotations from fastmcp.prompts.prompt import Prompt from fastmcp.resources.resource import Resource from fastmcp.tools.tool import Tool +from fastmcp.utilities.types import get_fn_name if TYPE_CHECKING: from fastmcp.server import FastMCP @@ -34,7 +35,7 @@ def mcp_tool( def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args = { - "name": name or func.__name__, + "name": name or get_fn_name(func), "description": description, "tags": tags, "annotations": annotations, @@ -63,7 +64,7 @@ def mcp_resource( def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args = { "uri": uri, - "name": name or func.__name__, + "name": name or get_fn_name(func), "description": description, "mime_type": mime_type, "tags": tags, @@ -88,7 +89,7 @@ def mcp_prompt( def decorator(func: Callable[..., Any]) -> Callable[..., Any]: call_args = { - "name": name or func.__name__, + "name": name or get_fn_name(func), "description": description, "tags": tags, "enabled": enabled, @@ -146,7 +147,21 @@ class MCPMixin: registration_info["name"] = ( f"{prefix}{separator}{registration_info['name']}" ) - tool = Tool.from_function(fn=method, **registration_info) + + tool = Tool.from_function( + fn=method, + name=registration_info.get("name"), + title=registration_info.get("title"), + description=registration_info.get("description"), + tags=registration_info.get("tags"), + annotations=registration_info.get("annotations"), + exclude_args=registration_info.get("exclude_args"), + serializer=registration_info.get("serializer"), + output_schema=registration_info.get("output_schema"), + meta=registration_info.get("meta"), + enabled=registration_info.get("enabled"), + ) + mcp_server.add_tool(tool) def register_resources( @@ -175,7 +190,19 @@ class MCPMixin: registration_info["uri"] = ( f"{prefix}{separator}{registration_info['uri']}" ) - resource = Resource.from_function(fn=method, **registration_info) + + resource = Resource.from_function( + fn=method, + uri=registration_info["uri"], + name=registration_info.get("name"), + description=registration_info.get("description"), + mime_type=registration_info.get("mime_type"), + tags=registration_info.get("tags"), + enabled=registration_info.get("enabled"), + annotations=registration_info.get("annotations"), + meta=registration_info.get("meta"), + ) + mcp_server.add_resource(resource) def register_prompts( @@ -200,7 +227,15 @@ class MCPMixin: registration_info["name"] = ( f"{prefix}{separator}{registration_info['name']}" ) - prompt = Prompt.from_function(fn=method, **registration_info) + prompt = Prompt.from_function( + fn=method, + name=registration_info.get("name"), + title=registration_info.get("title"), + description=registration_info.get("description"), + tags=registration_info.get("tags"), + enabled=registration_info.get("enabled"), + meta=registration_info.get("meta"), + ) mcp_server.add_prompt(prompt) def register_all( diff --git a/src/fastmcp/experimental/utilities/openapi/director.py b/src/fastmcp/experimental/utilities/openapi/director.py index b1dc47147..e7c860498 100644 --- a/src/fastmcp/experimental/utilities/openapi/director.py +++ b/src/fastmcp/experimental/utilities/openapi/director.py @@ -69,7 +69,14 @@ class RequestDirector: request_data["content"] = body # Step 5: Create httpx.Request - return httpx.Request(**{k: v for k, v in request_data.items() if v is not None}) + return httpx.Request( + method=request_data["method"], + url=request_data["url"], + params=request_data.get("params"), + headers=request_data.get("headers"), + json=request_data.get("json"), + content=request_data.get("content"), + ) def _unflatten_arguments( self, route: HTTPRoute, flat_args: dict[str, Any] diff --git a/src/fastmcp/prompts/prompt.py b/src/fastmcp/prompts/prompt.py index 1790d4a01..91c5cf31f 100644 --- a/src/fastmcp/prompts/prompt.py +++ b/src/fastmcp/prompts/prompt.py @@ -100,14 +100,16 @@ class Prompt(FastMCPComponent, ABC): ) for arg in self.arguments or [] ] - kwargs = { - "name": self.name, - "description": self.description, - "arguments": arguments, - "title": self.title, - "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), - } - return MCPPrompt(**kwargs | overrides) + + return MCPPrompt( + name=overrides.get("name", self.name), + description=overrides.get("description", self.description), + arguments=arguments, + title=overrides.get("title", self.title), + _meta=overrides.get( + "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta) + ), + ) @staticmethod def from_function( diff --git a/src/fastmcp/resources/resource.py b/src/fastmcp/resources/resource.py index eee7b1666..3067fb2c7 100644 --- a/src/fastmcp/resources/resource.py +++ b/src/fastmcp/resources/resource.py @@ -24,6 +24,7 @@ from fastmcp.server.dependencies import get_context from fastmcp.utilities.components import FastMCPComponent from fastmcp.utilities.types import ( find_kwarg_by_type, + get_fn_name, ) if TYPE_CHECKING: @@ -122,16 +123,18 @@ class Resource(FastMCPComponent, abc.ABC): **overrides: Any, ) -> MCPResource: """Convert the resource to an MCPResource.""" - kwargs = { - "uri": self.uri, - "name": self.name, - "description": self.description, - "mimeType": self.mime_type, - "title": self.title, - "annotations": self.annotations, - "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), - } - return MCPResource(**kwargs | overrides) + + return MCPResource( + name=overrides.get("name", self.name), + uri=overrides.get("uri", self.uri), + description=overrides.get("description", self.description), + mimeType=overrides.get("mimeType", self.mime_type), + title=overrides.get("title", self.title), + annotations=overrides.get("annotations", self.annotations), + _meta=overrides.get( + "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta) + ), + ) def __repr__(self) -> str: return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})" @@ -182,7 +185,7 @@ class FunctionResource(Resource): return cls( fn=fn, uri=uri, - name=name or fn.__name__, + name=name or get_fn_name(fn), title=title, description=description or inspect.getdoc(fn), mime_type=mime_type or "text/plain", diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py index 3bc8701eb..da18c7045 100644 --- a/src/fastmcp/resources/template.py +++ b/src/fastmcp/resources/template.py @@ -154,16 +154,18 @@ class ResourceTemplate(FastMCPComponent): **overrides: Any, ) -> MCPResourceTemplate: """Convert the resource template to an MCPResourceTemplate.""" - kwargs = { - "uriTemplate": self.uri_template, - "name": self.name, - "description": self.description, - "mimeType": self.mime_type, - "title": self.title, - "annotations": self.annotations, - "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), - } - return MCPResourceTemplate(**kwargs | overrides) + + return MCPResourceTemplate( + name=overrides.get("name", self.name), + uriTemplate=overrides.get("uriTemplate", self.uri_template), + description=overrides.get("description", self.description), + mimeType=overrides.get("mimeType", self.mime_type), + title=overrides.get("title", self.title), + annotations=overrides.get("annotations", self.annotations), + _meta=overrides.get( + "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta) + ), + ) @classmethod def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate: diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py index 23c402e53..24b3c1c07 100644 --- a/src/fastmcp/server/dependencies.py +++ b/src/fastmcp/server/dependencies.py @@ -5,6 +5,9 @@ from typing import TYPE_CHECKING from mcp.server.auth.middleware.auth_context import ( get_access_token as _sdk_get_access_token, ) +from mcp.server.auth.provider import ( + AccessToken as _SDKAccessToken, +) from starlette.requests import Request from fastmcp.server.auth import AccessToken @@ -107,17 +110,27 @@ def get_access_token() -> AccessToken | None: The access token if an authenticated user is available, None otherwise. """ # - obj = _sdk_get_access_token() - if obj is None or isinstance(obj, AccessToken): - return obj + access_token: _SDKAccessToken | None = _sdk_get_access_token() + + if access_token is None or isinstance(access_token, AccessToken): + return access_token # If the object is not a FastMCP AccessToken, convert it to one if the fields are compatible # This is a workaround for the case where the SDK returns a different type # If it fails, it will raise a TypeError try: - return AccessToken(**obj.model_dump()) + access_token_as_dict = access_token.model_dump() + return AccessToken( + token=access_token_as_dict["token"], + client_id=access_token_as_dict["client_id"], + scopes=access_token_as_dict["scopes"], + # Optional fields + expires_at=access_token_as_dict.get("expires_at"), + resource_owner=access_token_as_dict.get("resource_owner"), + claims=access_token_as_dict.get("claims"), + ) except Exception as e: raise TypeError( - f"Expected fastmcp.server.auth.auth.AccessToken, got {type(obj).__name__}. " + f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. " "Ensure the SDK is using the correct AccessToken type." ) from e diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index ed4ada427..0d77d26a5 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -812,6 +812,8 @@ class FastMCP(Generic[LifespanResultT]): Delegates to _get_prompt, which should be overridden by FastMCP subclasses. """ + import fastmcp.server.context + logger.debug( f"[{self.name}] Handler called: get_prompt %s with %s", name, arguments ) diff --git a/src/fastmcp/tools/tool.py b/src/fastmcp/tools/tool.py index d9c74b5d8..2b65e432c 100644 --- a/src/fastmcp/tools/tool.py +++ b/src/fastmcp/tools/tool.py @@ -138,23 +138,25 @@ class Tool(FastMCPComponent): include_fastmcp_meta: bool | None = None, **overrides: Any, ) -> MCPTool: + """Convert the FastMCP tool to an MCP tool.""" + title = None + if self.title: title = self.title elif self.annotations and self.annotations.title: title = self.annotations.title - else: - title = None - kwargs = { - "name": self.name, - "description": self.description, - "inputSchema": self.parameters, - "outputSchema": self.output_schema, - "annotations": self.annotations, - "title": title, - "_meta": self.get_meta(include_fastmcp_meta=include_fastmcp_meta), - } - return MCPTool(**kwargs | overrides) + return MCPTool( + name=overrides.get("name", self.name), + title=overrides.get("title", title), + description=overrides.get("description", self.description), + inputSchema=overrides.get("inputSchema", self.parameters), + outputSchema=overrides.get("outputSchema", self.output_schema), + annotations=overrides.get("annotations", self.annotations), + _meta=overrides.get( + "_meta", self.get_meta(include_fastmcp_meta=include_fastmcp_meta) + ), + ) @staticmethod def from_function( diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py index 151994c79..0bdea648c 100644 --- a/src/fastmcp/utilities/types.py +++ b/src/fastmcp/utilities/types.py @@ -31,6 +31,10 @@ NotSet = ... NotSetT: TypeAlias = EllipsisType +def get_fn_name(fn: Callable[..., Any]) -> str: + return fn.__name__ # ty: ignore[unresolved-attribute] + + class FastMCPBaseModel(BaseModel): """Base model for FastMCP models.""" @@ -80,11 +84,11 @@ def get_cached_typeadapter(cls: T) -> TypeAdapter[T]: # Handle both functions and methods if inspect.ismethod(cls): actual_func = cls.__func__ - code = actual_func.__code__ - globals_dict = actual_func.__globals__ - name = actual_func.__name__ - defaults = actual_func.__defaults__ - closure = actual_func.__closure__ + code = actual_func.__code__ # ty: ignore[unresolved-attribute] + globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute] + name = actual_func.__name__ # ty: ignore[unresolved-attribute] + defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute] + closure = actual_func.__closure__ # ty: ignore[unresolved-attribute] else: code = cls.__code__ globals_dict = cls.__globals__ diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py index 0d8b57c08..98c6bae10 100644 --- a/tests/cli/test_cli.py +++ b/tests/cli/test_cli.py @@ -32,6 +32,7 @@ class TestMainCLI: """Test parsing invalid environment variables exits.""" with pytest.raises(SystemExit) as exc_info: _parse_env_var("INVALID_FORMAT") + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 @@ -47,14 +48,14 @@ class TestVersionCommand: def test_version_command_parsing(self): """Test that the version command parses arguments correctly.""" command, bound, _ = app.parse_args(["version"]) - assert command.__name__ == "version" + assert command.__name__ == "version" # type: ignore[attr-defined] # Default arguments aren't included in bound.arguments assert bound.arguments == {} def test_version_command_with_copy_flag(self): """Test that the version command parses --copy flag correctly.""" command, bound, _ = app.parse_args(["version", "--copy"]) - assert command.__name__ == "version" + assert command.__name__ == "version" # type: ignore[attr-defined] assert bound.arguments == {"copy": True} @patch("fastmcp.cli.cli.pyperclip.copy") diff --git a/tests/cli/test_cursor.py b/tests/cli/test_cursor.py index be6da9c91..df40a7eab 100644 --- a/tests/cli/test_cursor.py +++ b/tests/cli/test_cursor.py @@ -353,4 +353,5 @@ class TestCursorCommand: with pytest.raises(SystemExit) as exc_info: await cursor_command("server.py") + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 diff --git a/tests/cli/test_mcp_server_config_integration.py b/tests/cli/test_mcp_server_config_integration.py index 0d88365d1..05a98687c 100644 --- a/tests/cli/test_mcp_server_config_integration.py +++ b/tests/cli/test_mcp_server_config_integration.py @@ -103,10 +103,13 @@ class TestConfigWithClient: spec = importlib.util.spec_from_file_location("test_server", str(source_path)) if spec is None or spec.loader is None: raise RuntimeError(f"Could not load module from {source_path}") + module = importlib.util.module_from_spec(spec) sys.modules["test_server"] = module spec.loader.exec_module(module) + assert hasattr(module, "mcp") + server = module.mcp # Connect client to server diff --git a/tests/cli/test_project_prepare.py b/tests/cli/test_project_prepare.py index 88e3fe5c2..93e37cb7e 100644 --- a/tests/cli/test_project_prepare.py +++ b/tests/cli/test_project_prepare.py @@ -257,6 +257,7 @@ class TestProjectPrepareCommand: with patch("fastmcp.cli.cli.logger.error") as mock_error: await prepare(config_path=None, output_dir=None) + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 mock_error.assert_called() error_msg = mock_error.call_args[0][0] @@ -275,6 +276,7 @@ class TestProjectPrepareCommand: with patch("fastmcp.cli.cli.logger.error") as mock_error: await prepare(config_path="missing.json", output_dir=None) + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 mock_error.assert_called() error_msg = mock_error.call_args[0][0] @@ -297,6 +299,7 @@ class TestProjectPrepareCommand: with patch("fastmcp.cli.cli.console.print") as mock_print: await prepare(config_path="config.json", output_dir="./test-env") + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 # Should print error message error_call = mock_print.call_args_list[-1][0][0] diff --git a/tests/cli/test_run.py b/tests/cli/test_run.py index 685e8aff0..7773684a7 100644 --- a/tests/cli/test_run.py +++ b/tests/cli/test_run.py @@ -81,6 +81,7 @@ class TestFileSystemSource: source = FileSystemSource(path="nonexistent.py") with pytest.raises(SystemExit) as exc_info: await source.load_server() + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 async def test_load_server_directory(self, tmp_path): @@ -88,6 +89,7 @@ class TestFileSystemSource: source = FileSystemSource(path=str(tmp_path)) with pytest.raises(SystemExit) as exc_info: await source.load_server() + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 @@ -252,6 +254,7 @@ other_name = fastmcp.FastMCP("OtherServer") source = FileSystemSource(path=str(test_file)) with pytest.raises(SystemExit) as exc_info: await source.load_server() + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 async def test_import_server_nonexistent_object_fails(self, tmp_path): @@ -266,6 +269,8 @@ mcp = fastmcp.FastMCP("TestServer") source = FileSystemSource(path=f"{test_file}:nonexistent") with pytest.raises(SystemExit) as exc_info: await source.load_server() + + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 diff --git a/tests/cli/test_run_with_uv.py b/tests/cli/test_run_with_uv.py index b076abe09..0cf6aedda 100644 --- a/tests/cli/test_run_with_uv.py +++ b/tests/cli/test_run_with_uv.py @@ -20,6 +20,7 @@ class TestRunWithUv: with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py") + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 0 # Check the command that was called @@ -45,6 +46,7 @@ class TestRunWithUv: with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py", python_version="3.11") + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] @@ -69,6 +71,7 @@ class TestRunWithUv: with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py", project=project_path) + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] @@ -91,6 +94,7 @@ class TestRunWithUv: with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py", with_packages=["pandas", "numpy"]) + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] @@ -116,6 +120,7 @@ class TestRunWithUv: with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py", with_requirements=req_path) + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] @@ -146,6 +151,7 @@ class TestRunWithUv: show_banner=False, ) + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] @@ -188,6 +194,7 @@ class TestRunWithUv: show_banner=False, ) + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 0 cmd = mock_run.call_args[0][0] @@ -231,6 +238,7 @@ class TestRunWithUv: with pytest.raises(SystemExit) as exc_info: run_with_uv("server.py") + assert isinstance(exc_info.value, SystemExit) assert exc_info.value.code == 1 @patch("fastmcp.cli.run.logger") diff --git a/tests/client/test_elicitation.py b/tests/client/test_elicitation.py index fcf7a94ca..1ad7bcdbf 100644 --- a/tests/client/test_elicitation.py +++ b/tests/client/test_elicitation.py @@ -36,7 +36,7 @@ def fastmcp_server(): response_type=Person, ) if result.action == "accept": - return f"Hello, {result.data.name}!" + return f"Hello, {result.data.name}!" # type: ignore[attr-defined] else: return "No name provided." @@ -128,7 +128,7 @@ async def test_elicitation_cancel_action(): if result.action == "cancel": return "Request was canceled" elif result.action == "accept": - return f"Age: {result.data}" + return f"Age: {result.data}" # type: ignore[attr-defined] else: return "No response provided" @@ -322,6 +322,9 @@ async def test_elicitation_handler_error(): async def failing_elicit(context: Context) -> str: try: result = await context.elicit(message="This will fail", response_type=str) + + assert isinstance(result, AcceptedElicitation) + assert result.action == "accept" return f"Got: {result.data}" except Exception as e: @@ -345,11 +348,17 @@ async def test_elicitation_multiple_calls(): name_result = await context.elicit( message="What's your name?", response_type=str ) + + assert isinstance(name_result, AcceptedElicitation) + if name_result.action != "accept": return "Form abandoned" # Second question age_result = await context.elicit(message="What's your age?", response_type=int) + + assert isinstance(age_result, AcceptedElicitation) + if age_result.action != "accept": return f"Hello {name_result.data}, form incomplete" @@ -403,6 +412,9 @@ async def test_structured_response_type( result = await context.elicit( message="Please provide your information", response_type=structured_type ) + + assert isinstance(result, AcceptedElicitation) + if result.action == "accept": if isinstance(result.data, dict): return f"User: {result.data['name']}, age: {result.data['age']}" diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index ccbcf3195..2286874bb 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -32,8 +32,9 @@ def fastmcp_server(): async def elicit(ctx: Context) -> str: """Elicit a response from the user.""" result = await ctx.elicit("What is your name?", response_type=str) + if result.action == "accept": - return f"You said your name was: {result.data}!" + return f"You said your name was: {result.data}!" # ty: ignore[possibly-unbound-attribute] else: return "No name provided" diff --git a/tests/conftest.py b/tests/conftest.py index d82413029..cffdbf831 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,6 @@ +from collections.abc import Callable +from typing import Any + import pytest @@ -15,3 +18,7 @@ def import_rich_rule(): import rich.rule # noqa: F401 yield + + +def get_fn_name(fn: Callable[..., Any]) -> str: + return fn.__name__ # ty: ignore[unresolved-attribute] diff --git a/tests/contrib/test_bulk_tool_caller.py b/tests/contrib/test_bulk_tool_caller.py index 578dcba96..7a0ce4415 100644 --- a/tests/contrib/test_bulk_tool_caller.py +++ b/tests/contrib/test_bulk_tool_caller.py @@ -1,6 +1,7 @@ from typing import Any import pytest +from inline_snapshot import snapshot from mcp.types import TextContent from fastmcp import FastMCP @@ -91,73 +92,116 @@ NO_RETURN_TOOL_NAME = "no_return_tool" async def test_call_tool_bulk_single_success(bulk_caller_live: BulkToolCaller): """Test single successful call via call_tool_bulk using echo_tool.""" - tool_arguments = [{"arg1": "value1"}] - expected_result = echo_tool_result_factory(**tool_arguments[0]) - results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments) + results = await bulk_caller_live.call_tool_bulk( + ECHO_TOOL_NAME, [{"arg1": "value1"}] + ) - assert len(results) == 1 - result = results[0] - assert result == expected_result + assert results == snapshot( + [ + CallToolRequestResult( + content=[TextContent(type="text", text="value1")], + tool="echo_tool", + arguments={"arg1": "value1"}, + ) + ] + ) async def test_call_tool_bulk_multiple_success(bulk_caller_live: BulkToolCaller): """Test multiple successful calls via call_tool_bulk using echo_tool.""" - tool_arguments = [{"arg1": "value1"}, {"arg1": "value2"}] - expected_results = [echo_tool_result_factory(**args) for args in tool_arguments] + results = await bulk_caller_live.call_tool_bulk( + ECHO_TOOL_NAME, [{"arg1": "value1"}, {"arg1": "value2"}] + ) - results = await bulk_caller_live.call_tool_bulk(ECHO_TOOL_NAME, tool_arguments) - - assert len(results) == 2 - assert results == expected_results + assert results == snapshot( + [ + CallToolRequestResult( + content=[TextContent(type="text", text="value1")], + tool="echo_tool", + arguments={"arg1": "value1"}, + ), + CallToolRequestResult( + content=[TextContent(type="text", text="value2")], + tool="echo_tool", + arguments={"arg1": "value2"}, + ), + ] + ) async def test_call_tool_bulk_error_stops(bulk_caller_live: BulkToolCaller): """Test call_tool_bulk stops on first error using error_tool.""" - tool_arguments = [{"arg1": "error_value"}, {"arg1": "value2"}] - expected_result = error_tool_result_factory(**tool_arguments[0]) - results = await bulk_caller_live.call_tool_bulk( - ERROR_TOOL_NAME, tool_arguments, continue_on_error=False + ERROR_TOOL_NAME, + [{"arg1": "error_value"}, {"arg1": "value2"}], + continue_on_error=False, ) - assert len(results) == 1 - result = results[0] - assert result == expected_result + assert results == snapshot( + [ + CallToolRequestResult( + content=[ + TextContent( + type="text", + text="Error calling tool 'error_tool': Error in tool with arg1: error_value", + ) + ], + isError=True, + tool="error_tool", + arguments={"arg1": "error_value"}, + ) + ] + ) async def test_call_tool_bulk_error_continues(bulk_caller_live: BulkToolCaller): """Test call_tool_bulk continues on error using error_tool and echo_tool.""" - tool_arguments = [{"arg1": "error_value"}, {"arg1": "success_value"}] - expected_error_result = error_tool_result_factory(**tool_arguments[0]) - expected_success_result = echo_tool_result_factory(**tool_arguments[1]) tool_calls = [ - CallToolRequest(tool=ERROR_TOOL_NAME, arguments=tool_arguments[0]), - CallToolRequest(tool=ECHO_TOOL_NAME, arguments=tool_arguments[1]), + CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}), + CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "success_value"}), ] results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True) - assert len(results) == 2 - - error_result = results[0] - assert error_result == expected_error_result - - success_result = results[1] - assert success_result == expected_success_result + assert results == snapshot( + [ + CallToolRequestResult( + content=[ + TextContent( + type="text", + text="Error calling tool 'error_tool': Error in tool with arg1: error_value", + ) + ], + isError=True, + tool="error_tool", + arguments={"arg1": "error_value"}, + ), + CallToolRequestResult( + content=[TextContent(type="text", text="success_value")], + tool="echo_tool", + arguments={"arg1": "success_value"}, + ), + ] + ) async def test_call_tools_bulk_single_success(bulk_caller_live: BulkToolCaller): """Test single successful call via call_tools_bulk using echo_tool.""" tool_calls = [CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "value1"})] - expected_result = echo_tool_result_factory(**tool_calls[0].arguments) results = await bulk_caller_live.call_tools_bulk(tool_calls) - assert len(results) == 1 - result = results[0] - assert result == expected_result + assert results == snapshot( + [ + CallToolRequestResult( + content=[TextContent(type="text", text="value1")], + tool="echo_tool", + arguments={"arg1": "value1"}, + ) + ] + ) async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller): @@ -168,15 +212,21 @@ async def test_call_tools_bulk_multiple_success(bulk_caller_live: BulkToolCaller tool=NO_RETURN_TOOL_NAME, arguments={"arg1": "no_return_value"} ), ] - expected_results = [ - echo_tool_result_factory(**tool_calls[0].arguments), - no_return_tool_result_factory(**tool_calls[1].arguments), - ] results = await bulk_caller_live.call_tools_bulk(tool_calls) - assert len(results) == 2 - assert results == expected_results + assert results == snapshot( + [ + CallToolRequestResult( + content=[TextContent(type="text", text="echo_value")], + tool="echo_tool", + arguments={"arg1": "echo_value"}, + ), + CallToolRequestResult( + content=[], tool="no_return_tool", arguments={"arg1": "no_return_value"} + ), + ] + ) async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller): @@ -185,15 +235,26 @@ async def test_call_tools_bulk_error_stops(bulk_caller_live: BulkToolCaller): CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}), CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "skipped_value"}), ] - expected_result = error_tool_result_factory(**tool_calls[0].arguments) results = await bulk_caller_live.call_tools_bulk( tool_calls, continue_on_error=False ) - assert len(results) == 1 - result = results[0] - assert result == expected_result + assert results == snapshot( + [ + CallToolRequestResult( + content=[ + TextContent( + type="text", + text="Error calling tool 'error_tool': Error in tool with arg1: error_value", + ) + ], + isError=True, + tool="error_tool", + arguments={"arg1": "error_value"}, + ) + ] + ) async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller): @@ -202,15 +263,26 @@ async def test_call_tools_bulk_error_continues(bulk_caller_live: BulkToolCaller) CallToolRequest(tool=ERROR_TOOL_NAME, arguments={"arg1": "error_value"}), CallToolRequest(tool=ECHO_TOOL_NAME, arguments={"arg1": "success_value"}), ] - expected_error_result = error_tool_result_factory(**tool_calls[0].arguments) - expected_success_result = echo_tool_result_factory(**tool_calls[1].arguments) results = await bulk_caller_live.call_tools_bulk(tool_calls, continue_on_error=True) - assert len(results) == 2 - - error_result = results[0] - assert error_result == expected_error_result - - success_result = results[1] - assert success_result == expected_success_result + assert results == snapshot( + [ + CallToolRequestResult( + content=[ + TextContent( + type="text", + text="Error calling tool 'error_tool': Error in tool with arg1: error_value", + ) + ], + isError=True, + tool="error_tool", + arguments={"arg1": "error_value"}, + ), + CallToolRequestResult( + content=[TextContent(type="text", text="success_value")], + tool="echo_tool", + arguments={"arg1": "success_value"}, + ), + ] + ) diff --git a/tests/experimental/openapi_parser/utilities/openapi/test_models.py b/tests/experimental/openapi_parser/utilities/openapi/test_models.py index 9b9c3d2e8..2a77be5f4 100644 --- a/tests/experimental/openapi_parser/utilities/openapi/test_models.py +++ b/tests/experimental/openapi_parser/utilities/openapi/test_models.py @@ -1,6 +1,7 @@ """Unit tests for OpenAPI models.""" import pytest +from inline_snapshot import snapshot from fastmcp.experimental.utilities.openapi.models import ( HTTPRoute, @@ -447,7 +448,27 @@ class TestModelSerialization: # Serialize and reconstruct using by_alias data = original_param.model_dump(by_alias=True) - reconstructed_param = ParameterInfo(**data) + + data == snapshot( + { + "name": "test", + "location": "query", + "required": False, + "schema": {"type": "string"}, + "description": "Test parameter", + "explode": None, + "style": None, + } + ) + + reconstructed_param = ParameterInfo( + name=data["name"], + location=data["location"], + schema=data["schema"], + description=data["description"], + explode=data["explode"], + style=data["style"], + ) assert reconstructed_param.name == original_param.name assert reconstructed_param.location == original_param.location diff --git a/tests/prompts/test_prompt_manager.py b/tests/prompts/test_prompt_manager.py index a002c1b25..61b0e991e 100644 --- a/tests/prompts/test_prompt_manager.py +++ b/tests/prompts/test_prompt_manager.py @@ -8,6 +8,7 @@ from fastmcp.prompts import Prompt from fastmcp.prompts.prompt import FunctionPrompt, PromptMessage, TextContent from fastmcp.prompts.prompt_manager import PromptManager from fastmcp.utilities.tests import caplog_for_fastmcp +from tests.conftest import get_fn_name class TestPromptManager: @@ -104,7 +105,7 @@ class TestPromptManager: prompt = await manager.get_prompt("test_prompt") assert prompt is not None assert isinstance(prompt, FunctionPrompt) - assert prompt.fn.__name__ == "replacement_fn" + assert get_fn_name(prompt.fn) == "replacement_fn" async def test_ignore_duplicate_prompts(self): """Test ignoring duplicate prompts.""" @@ -126,10 +127,10 @@ class TestPromptManager: prompt = await manager.get_prompt("test_prompt") assert prompt is not None assert isinstance(prompt, FunctionPrompt) - assert prompt.fn.__name__ == "original_fn" + assert get_fn_name(prompt.fn) == "original_fn" # Result should be the original prompt assert isinstance(result, FunctionPrompt) - assert result.fn.__name__ == "original_fn" + assert get_fn_name(result.fn) == "original_fn" async def test_get_prompts(self): """Test retrieving all prompts.""" diff --git a/tests/resources/test_resource_template.py b/tests/resources/test_resource_template.py index c6a7afcd7..68178208c 100644 --- a/tests/resources/test_resource_template.py +++ b/tests/resources/test_resource_template.py @@ -27,8 +27,8 @@ class TestResourceTemplate: assert template.uri_template == "test://{key}/{value}" assert template.name == "test" assert template.mime_type == "text/plain" # default - test_input = {"key": "test", "value": 42} - assert template.fn(**test_input) == my_func(**test_input) + + assert template.fn(key="test", value=42) == my_func(key="test", value=42) def test_template_matches(self): """Test matching URIs against a template.""" diff --git a/tests/server/auth/providers/test_workos.py b/tests/server/auth/providers/test_workos.py index d0b77e758..e32187009 100644 --- a/tests/server/auth/providers/test_workos.py +++ b/tests/server/auth/providers/test_workos.py @@ -195,6 +195,8 @@ class TestAuthKitProvider: with pytest.raises(httpx.HTTPStatusError) as exc_info: async with Client(mcp_server_url) as client: tools = await client.list_tools() # noqa: F841 + + assert isinstance(exc_info.value, httpx.HTTPStatusError) assert exc_info.value.response.status_code == 401 assert "tools" not in locals() diff --git a/tests/server/auth/test_jwt_provider.py b/tests/server/auth/test_jwt_provider.py index 1f41fbea6..d20159460 100644 --- a/tests/server/auth/test_jwt_provider.py +++ b/tests/server/auth/test_jwt_provider.py @@ -978,6 +978,7 @@ class TestFastMCPBearerAuth: with pytest.raises(httpx.HTTPStatusError) as exc_info: async with Client(mcp_server_url) as client: tools = await client.list_tools() # noqa: F841 + assert isinstance(exc_info.value, httpx.HTTPStatusError) assert exc_info.value.response.status_code == 401 assert "tools" not in locals() @@ -990,6 +991,7 @@ class TestFastMCPBearerAuth: 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 isinstance(exc_info.value, httpx.HTTPStatusError) assert exc_info.value.response.status_code == 401 assert "tools" not in locals() @@ -1004,6 +1006,7 @@ class TestFastMCPBearerAuth: 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 isinstance(exc_info.value, httpx.HTTPStatusError) assert exc_info.value.response.status_code == 401 assert "tools" not in locals() @@ -1014,6 +1017,7 @@ class TestFastMCPBearerAuth: 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 isinstance(exc_info.value, httpx.HTTPStatusError) assert exc_info.value.response.status_code == 401 assert "tools" not in locals() @@ -1040,6 +1044,7 @@ class TestFastMCPBearerAuth: # JWTVerifier returns 401 when verify_token returns None (invalid token) # This is correct behavior - when TokenVerifier.verify_token returns None, # it indicates the token is invalid (not just insufficient permissions) + assert isinstance(exc_info.value, httpx.HTTPStatusError) assert exc_info.value.response.status_code == 401 assert "tools" not in locals() diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py index 82b5e0aca..4fffb1cf8 100644 --- a/tests/server/auth/test_oauth_proxy.py +++ b/tests/server/auth/test_oauth_proxy.py @@ -365,6 +365,7 @@ class TestOAuthProxyInitialization: assert proxy._redirect_path == "/custom/callback" assert proxy._forward_pkce is False assert proxy._token_endpoint_auth_method == "client_secret_post" + assert proxy.client_registration_options is not None assert proxy.client_registration_options.valid_scopes == ["custom", "scopes"] def test_redirect_path_normalization(self, jwt_verifier): diff --git a/tests/server/middleware/test_error_handling.py b/tests/server/middleware/test_error_handling.py index ee61ba9b6..0983cfb1c 100644 --- a/tests/server/middleware/test_error_handling.py +++ b/tests/server/middleware/test_error_handling.py @@ -193,6 +193,7 @@ class TestErrorHandlingMiddleware: with pytest.raises(McpError) as exc_info: await middleware.on_message(mock_context, mock_call_next) + assert isinstance(exc_info.value, McpError) assert exc_info.value.error.code == -32602 assert "Invalid params: test error" in exc_info.value.error.message assert "Error in test_method: ValueError: test error" in caplog.text diff --git a/tests/server/middleware/test_logging.py b/tests/server/middleware/test_logging.py index d0118f219..550068fde 100644 --- a/tests/server/middleware/test_logging.py +++ b/tests/server/middleware/test_logging.py @@ -7,6 +7,7 @@ from typing import Any, Literal, TypeVar from unittest.mock import AsyncMock, MagicMock import mcp +import mcp.types import pytest from inline_snapshot import snapshot from pydantic import AnyUrl @@ -56,7 +57,7 @@ def mock_context(): @pytest.fixture -def mock_call_next(): +def mock_call_next() -> AsyncMock: """Create a mock call_next function.""" return AsyncMock(return_value="test_result") @@ -118,11 +119,11 @@ class TestLoggingMiddleware: async def test_on_message_success( self, mock_context: MiddlewareContext[Any], - mock_call_next: CallNext[Any, Any], caplog: pytest.LogCaptureFixture, ): """Test logging successful messages.""" middleware = LoggingMiddleware() + mock_call_next = AsyncMock(return_value="test_result") with caplog.at_level(logging.INFO): result = await middleware.on_message(mock_context, mock_call_next) diff --git a/tests/server/openapi/conftest.py b/tests/server/openapi/conftest.py index 571fd4d7d..9a7b58f4d 100644 --- a/tests/server/openapi/conftest.py +++ b/tests/server/openapi/conftest.py @@ -88,7 +88,7 @@ def fastapi_app(users_db: dict[int, User]) -> FastAPI: async def create_user(user: UserCreate) -> User: """Create a new user.""" user_id = max(users_db.keys()) + 1 - new_user = User(id=user_id, **user.model_dump()) + new_user = User(id=user_id, name=user.name, active=user.active) users_db[user_id] = new_user return new_user diff --git a/tests/server/proxy/test_proxy_client.py b/tests/server/proxy/test_proxy_client.py index 436f66f89..e5414d86e 100644 --- a/tests/server/proxy/test_proxy_client.py +++ b/tests/server/proxy/test_proxy_client.py @@ -51,8 +51,9 @@ def fastmcp_server(): message="What is your name?", response_type=Person, ) + if result.action == "accept": - return f"Hello, {result.data.name}!" + return f"Hello, {result.data.name}!" # type: ignore[attr-defined] else: return "No name provided." @@ -362,7 +363,7 @@ class TestProxyClient: ) if result.action == "accept": - return f"Content: {result.data.content}, Acknowledge: {result.data.acknowledge}" + return f"Content: {result.data.content}, Acknowledge: {result.data.acknowledge}" # type: ignore[attr-defined] else: return f"Elicitation {result.action}" diff --git a/tests/server/test_import_server.py b/tests/server/test_import_server.py index 8392d9740..0d5661e17 100644 --- a/tests/server/test_import_server.py +++ b/tests/server/test_import_server.py @@ -4,6 +4,7 @@ from urllib.parse import quote from fastmcp.client.client import Client from fastmcp.server.server import FastMCP from fastmcp.tools.tool import FunctionTool, Tool +from tests.conftest import get_fn_name async def test_import_basic_functionality(): @@ -208,7 +209,7 @@ async def test_tool_custom_name_preserved_when_imported(): # Check that the function name is preserved assert isinstance(tool, FunctionTool) - assert tool.fn.__name__ == "fetch_data" + assert get_fn_name(tool.fn) == "fetch_data" async def test_call_imported_custom_named_tool(): @@ -242,7 +243,7 @@ async def test_first_level_importing_with_custom_name(): tool = await service_app._tool_manager.get_tool("provider_compute") assert tool is not None assert isinstance(tool, FunctionTool) - assert tool.fn.__name__ == "calculate_value" + assert get_fn_name(tool.fn) == "calculate_value" async def test_nested_importing_preserves_prefixes(): diff --git a/tests/tools/test_tool_manager.py b/tests/tools/test_tool_manager.py index 1ab362a0d..aae9850ee 100644 --- a/tests/tools/test_tool_manager.py +++ b/tests/tools/test_tool_manager.py @@ -15,6 +15,7 @@ from fastmcp.tools.tool import Tool from fastmcp.tools.tool_transform import ArgTransformConfig, ToolTransformConfig from fastmcp.utilities.tests import caplog_for_fastmcp, temporary_settings from fastmcp.utilities.types import Image +from tests.conftest import get_fn_name class TestAddTools: @@ -236,7 +237,7 @@ class TestAddTools: tool = await manager.get_tool("test_tool") assert tool is not None assert isinstance(tool, FunctionTool) - assert tool.fn.__name__ == "replacement_fn" + assert get_fn_name(tool.fn) == "replacement_fn" async def test_ignore_duplicate_tools(self): """Test ignoring duplicate tools.""" @@ -257,10 +258,10 @@ class TestAddTools: tool = await manager.get_tool("test_tool") assert tool is not None assert isinstance(tool, FunctionTool) - assert tool.fn.__name__ == "original_fn" + assert get_fn_name(tool.fn) == "original_fn" # Result should be the original tool assert isinstance(result, FunctionTool) - assert result.fn.__name__ == "replacement_fn" + assert get_fn_name(result.fn) == "replacement_fn" class TestListTools: @@ -824,7 +825,7 @@ class TestCustomToolNames: assert await manager.get_tool("custom_name") is not None assert tool.name == "custom_name" assert isinstance(tool, FunctionTool) - assert tool.fn.__name__ == "original_fn" + assert get_fn_name(tool.fn) == "original_fn" # The tool should not be accessible via its original function name with pytest.raises(NotFoundError, match="Tool 'original_fn' not found"): await manager.get_tool("original_fn") @@ -901,7 +902,7 @@ class TestCustomToolNames: # But the function is different assert isinstance(stored_tool, FunctionTool) - assert stored_tool.fn.__name__ == "replacement_fn" + assert get_fn_name(stored_tool.fn) == "replacement_fn" class TestToolErrorHandling: diff --git a/tests/utilities/test_json_schema_type.py b/tests/utilities/test_json_schema_type.py index 8cb9ca90a..fc3ccdb84 100644 --- a/tests/utilities/test_json_schema_type.py +++ b/tests/utilities/test_json_schema_type.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import Field, dataclass from datetime import datetime from enum import Enum from typing import Any, Literal, Union @@ -13,6 +13,10 @@ from fastmcp.utilities.json_schema_type import ( ) +def get_dataclass_field(type: type, field_name: str) -> Field: + return type.__dataclass_fields__[field_name] # ty: ignore[unresolved-attribute] + + class TestSimpleTypes: """Test suite for basic type validation.""" @@ -990,8 +994,8 @@ class TestSchemaCaching: # Both main classes and their nested classes should be identical assert class1 is class2 assert ( - class1.__dataclass_fields__["nested"].type - is class2.__dataclass_fields__["nested"].type + get_dataclass_field(class1, "nested").type + is get_dataclass_field(class2, "nested").type ) @@ -1220,9 +1224,10 @@ class TestNameHandling: } Type = json_schema_to_type(schema) assert Type.__name__ == "Parent" - assert Type.__dataclass_fields__["child"].type.__origin__ is Union - assert Type.__dataclass_fields__["child"].type.__args__[0].__name__ == "Child" - assert Type.__dataclass_fields__["child"].type.__args__[1] is type(None) + child_field_type = get_dataclass_field(Type, "child").type + assert child_field_type.__origin__ is Union # ty: ignore[possibly-unbound-attribute] + assert child_field_type.__args__[0].__name__ == "Child" # ty: ignore[possibly-unbound-attribute] + assert child_field_type.__args__[1] is type(None) # ty: ignore[possibly-unbound-attribute] def test_recursive_schema_naming(self): schema = { @@ -1232,11 +1237,12 @@ class TestNameHandling: } Type = json_schema_to_type(schema) assert Type.__name__ == "Node" - assert Type.__dataclass_fields__["next"].type.__origin__ is Union - assert ( - Type.__dataclass_fields__["next"].type.__args__[0].__forward_arg__ == "Node" - ) - assert Type.__dataclass_fields__["next"].type.__args__[1] is type(None) + + next_field_type = get_dataclass_field(Type, "next").type + + assert next_field_type.__origin__ is Union # ty: ignore[possibly-unbound-attribute] + assert next_field_type.__args__[0].__forward_arg__ == "Node" # ty: ignore[possibly-unbound-attribute] + assert next_field_type.__args__[1] is type(None) # ty: ignore[possibly-unbound-attribute] def test_name_caching_with_different_titles(self): """Ensure schemas with different titles create different cached classes""" diff --git a/uv.lock b/uv.lock index 5ab3d1554..9a5ad0c57 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.11'", @@ -2045,27 +2045,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.1a19" +version = "0.0.1a20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/04/281c1a3c9c53dae5826b9d01a3412de653e3caf1ca50ce1265da66e06d73/ty-0.0.1a19.tar.gz", hash = "sha256:894f6a13a43989c8ef891ae079b3b60a0c0eae00244abbfbbe498a3840a235ac", size = 4098412, upload-time = "2025-08-19T13:29:58.559Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/82/a5e3b4bc5280ec49c4b0b43d0ff727d58c7df128752c9c6f97ad0b5f575f/ty-0.0.1a20.tar.gz", hash = "sha256:933b65a152f277aa0e23ba9027e5df2c2cc09e18293e87f2a918658634db5f15", size = 4194773, upload-time = "2025-09-03T12:35:46.775Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3e/65/a61cfcc7248b0257a3110bf98d3d910a4729c1063abdbfdcd1cad9012323/ty-0.0.1a19-py3-none-linux_armv6l.whl", hash = "sha256:e0e7762f040f4bab1b37c57cb1b43cc3bc5afb703fa5d916dfcafa2ef885190e", size = 8143744, upload-time = "2025-08-19T13:29:13.88Z" }, - { url = "https://files.pythonhosted.org/packages/02/d9/232afef97d9afa2274d23a4c49a3ad690282ca9696e1b6bbb6e4e9a1b072/ty-0.0.1a19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cd0a67ac875f49f34d9a0b42dcabf4724194558a5dd36867209d5695c67768f7", size = 8305799, upload-time = "2025-08-19T13:29:17.322Z" }, - { url = "https://files.pythonhosted.org/packages/20/14/099d268da7a9cccc6ba38dfc124f6742a1d669bc91f2c61a3465672b4f71/ty-0.0.1a19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ff8b1c0b85137333c39eccd96c42603af8ba7234d6e2ed0877f66a4a26750dd4", size = 7901431, upload-time = "2025-08-19T13:29:21.635Z" }, - { url = "https://files.pythonhosted.org/packages/c2/cd/3f1ca6e1d7f77cc4d08910a3fc4826313c031c0aae72286ae859e737670c/ty-0.0.1a19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fef34a29f4b97d78aa30e60adbbb12137cf52b8b2b0f1a408dd0feb0466908a", size = 8051501, upload-time = "2025-08-19T13:29:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/47/72/ddbec39f48ce3f5f6a3fa1f905c8fff2873e59d2030f738814032bd783e3/ty-0.0.1a19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0f219cb43c0c50fc1091f8ebd5548d3ef31ee57866517b9521d5174978af9fd", size = 7981234, upload-time = "2025-08-19T13:29:25.839Z" }, - { url = "https://files.pythonhosted.org/packages/f2/0f/58e76b8d4634df066c790d362e8e73b25852279cd6f817f099b42a555a66/ty-0.0.1a19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22abb6c1f14c65c1a2fafd38e25dd3c87994b3ab88cb0b323235b51dbad082d9", size = 8916394, upload-time = "2025-08-19T13:29:27.932Z" }, - { url = "https://files.pythonhosted.org/packages/70/30/01bfd93ccde11540b503e2539e55f6a1fc6e12433a229191e248946eb753/ty-0.0.1a19-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5b49225c349a3866e38dd297cb023a92d084aec0e895ed30ca124704bff600e6", size = 9412024, upload-time = "2025-08-19T13:29:30.942Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a2/2216d752f5f22c5c0995f9b13f18337301220f2a7d952c972b33e6a63583/ty-0.0.1a19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:88f41728b3b07402e0861e3c34412ca963268e55f6ab1690208f25d37cb9d63c", size = 9032657, upload-time = "2025-08-19T13:29:33.933Z" }, - { url = "https://files.pythonhosted.org/packages/24/c7/e6650b0569be1b69a03869503d07420c9fb3e90c9109b09726c44366ce63/ty-0.0.1a19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33814a1197ec3e930fcfba6fb80969fe7353957087b42b88059f27a173f7510b", size = 8812775, upload-time = "2025-08-19T13:29:36.505Z" }, - { url = "https://files.pythonhosted.org/packages/35/c6/b8a20e06b97fe8203059d56d8f91cec4f9633e7ba65f413d80f16aa0be04/ty-0.0.1a19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71b7f2b674a287258f628acafeecd87691b169522945ff6192cd8a69af15857", size = 8631417, upload-time = "2025-08-19T13:29:38.837Z" }, - { url = "https://files.pythonhosted.org/packages/be/99/821ca1581dcf3d58ffb7bbe1cde7e1644dbdf53db34603a16a459a0b302c/ty-0.0.1a19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a7f8ef9ac4c38e8651c18c7380649c5a3fa9adb1a6012c721c11f4bbdc0ce24", size = 7928900, upload-time = "2025-08-19T13:29:41.08Z" }, - { url = "https://files.pythonhosted.org/packages/08/cb/59f74a0522e57565fef99e2287b2bc803ee47ff7dac250af26960636939f/ty-0.0.1a19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:60f40e72f0fbf4e54aa83d9a6cb1959f551f83de73af96abbb94711c1546bd60", size = 8003310, upload-time = "2025-08-19T13:29:43.165Z" }, - { url = "https://files.pythonhosted.org/packages/4c/b3/1209b9acb5af00a2755114042e48fb0f71decc20d9d77a987bf5b3d1a102/ty-0.0.1a19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:64971e4d3e3f83dc79deb606cc438255146cab1ab74f783f7507f49f9346d89d", size = 8496463, upload-time = "2025-08-19T13:29:46.136Z" }, - { url = "https://files.pythonhosted.org/packages/a2/d6/a4b6ba552d347a08196d83a4d60cb23460404a053dd3596e23a922bce544/ty-0.0.1a19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9aadbff487e2e1486e83543b4f4c2165557f17432369f419be9ba48dc47625ca", size = 8700633, upload-time = "2025-08-19T13:29:49.351Z" }, - { url = "https://files.pythonhosted.org/packages/96/c5/258f318d68b95685c8d98fb654a38882c9d01ce5d9426bed06124f690f04/ty-0.0.1a19-py3-none-win32.whl", hash = "sha256:00b75b446357ee22bcdeb837cb019dc3bc1dc5e5013ff0f46a22dfe6ce498fe2", size = 7811441, upload-time = "2025-08-19T13:29:52.077Z" }, - { url = "https://files.pythonhosted.org/packages/fb/bb/039227eee3c0c0cddc25f45031eea0f7f10440713f12d333f2f29cf8e934/ty-0.0.1a19-py3-none-win_amd64.whl", hash = "sha256:aaef76b2f44f6379c47adfe58286f0c56041cb2e374fd8462ae8368788634469", size = 8441186, upload-time = "2025-08-19T13:29:54.53Z" }, - { url = "https://files.pythonhosted.org/packages/74/5f/bceb29009670ae6f759340f9cb434121bc5ed84ad0f07bdc6179eaaa3204/ty-0.0.1a19-py3-none-win_arm64.whl", hash = "sha256:893755bb35f30653deb28865707e3b16907375c830546def2741f6ff9a764710", size = 8000810, upload-time = "2025-08-19T13:29:56.796Z" }, + { url = "https://files.pythonhosted.org/packages/45/c8/f7d39392043d5c04936f6cad90e50eb661965ed092ca4bfc01db917d7b8a/ty-0.0.1a20-py3-none-linux_armv6l.whl", hash = "sha256:f73a7aca1f0d38af4d6999b375eb00553f3bfcba102ae976756cc142e14f3450", size = 8443599, upload-time = "2025-09-03T12:35:04.289Z" }, + { url = "https://files.pythonhosted.org/packages/1e/57/5aec78f9b8a677b7439ccded7d66c3361e61247e0f6b14e659b00dd01008/ty-0.0.1a20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cad12c857ea4b97bf61e02f6796e13061ccca5e41f054cbd657862d80aa43bae", size = 8618102, upload-time = "2025-09-03T12:35:07.448Z" }, + { url = "https://files.pythonhosted.org/packages/15/20/50c9107d93cdb55676473d9dc4e2339af6af606660c9428d3b86a1b2a476/ty-0.0.1a20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f153b65c7fcb6b8b59547ddb6353761b3e8d8bb6f0edd15e3e3ac14405949f7a", size = 8192167, upload-time = "2025-09-03T12:35:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/85/28/018b2f330109cee19e81c5ca9df3dc29f06c5778440eb9af05d4550c4302/ty-0.0.1a20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8c4336987a6a781d4392a9fd7b3a39edb7e4f3dd4f860e03f46c932b52aefa2", size = 8349256, upload-time = "2025-09-03T12:35:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c9/2f8797a05587158f52b142278796ffd72c893bc5ad41840fce5aeb65c6f2/ty-0.0.1a20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ff75cd4c744d09914e8c9db8d99e02f82c9379ad56b0a3fc4c5c9c923cfa84e", size = 8271214, upload-time = "2025-09-03T12:35:13.741Z" }, + { url = "https://files.pythonhosted.org/packages/30/d4/2cac5e5eb9ee51941358cb3139aadadb59520cfaec94e4fcd2b166969748/ty-0.0.1a20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e26437772be7f7808868701f2bf9e14e706a6ec4c7d02dbd377ff94d7ba60c11", size = 9264939, upload-time = "2025-09-03T12:35:16.896Z" }, + { url = "https://files.pythonhosted.org/packages/93/96/a6f2b54e484b2c6a5488f217882237dbdf10f0fdbdb6cd31333d57afe494/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:83a7ee12465841619b5eb3ca962ffc7d576bb1c1ac812638681aee241acbfbbe", size = 9743137, upload-time = "2025-09-03T12:35:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/95b40dcbec3d222f3af5fe5dd1ce066d42f8a25a2f70d5724490457048e7/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:726d0738be4459ac7ffae312ba96c5f486d6cbc082723f322555d7cba9397871", size = 9368153, upload-time = "2025-09-03T12:35:22.569Z" }, + { url = "https://files.pythonhosted.org/packages/2c/24/689fa4c4270b9ef9a53dc2b1d6ffade259ba2c4127e451f0629e130ea46a/ty-0.0.1a20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b481f26513f38543df514189fb16744690bcba8d23afee95a01927d93b46e36", size = 9099637, upload-time = "2025-09-03T12:35:24.94Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/913011cbf3ea4030097fb3c4ce751856114c9e1a5e1075561a4c5242af9b/ty-0.0.1a20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7abbe3c02218c12228b1d7c5f98c57240029cc3bcb15b6997b707c19be3908c1", size = 8952000, upload-time = "2025-09-03T12:35:27.288Z" }, + { url = "https://files.pythonhosted.org/packages/df/f9/f5ba2ae455b20c5bb003f9940ef8142a8c4ed9e27de16e8f7472013609db/ty-0.0.1a20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fff51c75ee3f7cc6d7722f2f15789ef8ffe6fd2af70e7269ac785763c906688e", size = 8217938, upload-time = "2025-09-03T12:35:29.54Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/17002cf9032f0981cdb8c898d02422c095c30eefd69ca62a8b705d15bd0f/ty-0.0.1a20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b4124ab75e0e6f09fe7bc9df4a77ee43c5e0ef7e61b0c149d7c089d971437cbd", size = 8292369, upload-time = "2025-09-03T12:35:31.748Z" }, + { url = "https://files.pythonhosted.org/packages/28/d6/0879b1fb66afe1d01d45c7658f3849aa641ac4ea10679404094f3b40053e/ty-0.0.1a20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8a138fa4f74e6ed34e9fd14652d132409700c7ff57682c2fed656109ebfba42f", size = 8811973, upload-time = "2025-09-03T12:35:33.997Z" }, + { url = "https://files.pythonhosted.org/packages/60/1e/70bf0348cfe8ba5f7532983f53c508c293ddf5fa9f942ed79a3c4d576df3/ty-0.0.1a20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8eff8871d6b88d150e2a67beba2c57048f20c090c219f38ed02eebaada04c124", size = 9010990, upload-time = "2025-09-03T12:35:36.766Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ca/03d85c7650359247b1ca3f38a0d869a608ef540450151920e7014ed58292/ty-0.0.1a20-py3-none-win32.whl", hash = "sha256:3c2ace3a22fab4bd79f84c74e3dab26e798bfba7006bea4008d6321c1bd6efc6", size = 8100746, upload-time = "2025-09-03T12:35:40.007Z" }, + { url = "https://files.pythonhosted.org/packages/94/53/7a1937b8c7a66d0c8ed7493de49ed454a850396fe137d2ae12ed247e0b2f/ty-0.0.1a20-py3-none-win_amd64.whl", hash = "sha256:f41e77ff118da3385915e13c3f366b3a2f823461de54abd2e0ca72b170ba0f19", size = 8748861, upload-time = "2025-09-03T12:35:42.175Z" }, + { url = "https://files.pythonhosted.org/packages/27/36/5a3a70c5d497d3332f9e63cabc9c6f13484783b832fecc393f4f1c0c4aa8/ty-0.0.1a20-py3-none-win_arm64.whl", hash = "sha256:d8ac1c5a14cda5fad1a8b53959d9a5d979fe16ce1cc2785ea8676fed143ac85f", size = 8269906, upload-time = "2025-09-03T12:35:45.045Z" }, ] [[package]]