From d89b3a53d4f84c849ead9ce8aea4124bb604c53e Mon Sep 17 00:00:00 2001 From: Eric Gustin <34000337+EricGustin@users.noreply.github.com> Date: Mon, 3 Nov 2025 11:26:51 -0800 Subject: [PATCH] Fix: Skip currently executing file during tool discovery (#668) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### The Bug: When an entrypoint file imports its parent package and calls add_tools_from_module() on that package, and the same entrypoint file also defines tools using @app.tool or @tool decorators, then the server fails to start with an `AttributeError`. This is because the tools would be discovered via AST parsing, but those tools weren't added to the module's namespace yet because the file is still executing. For example, this would fail on startup: ```py #!/usr/bin/env python3 """local_filesystem MCP server""" import sys from typing import Annotated from arcade_mcp_server import MCPApp import local_filesystem app = MCPApp(name="eric_server", version="1.0.0", log_level="DEBUG") app.add_tools_from_module(local_filesystem) @app.tool def eric(name: Annotated[str, "The name of the person to greet"]) -> str: """Greet a person by name.""" return "return" if __name__ == "__main__": transport = sys.argv[1] if len(sys.argv) > 1 else "stdio" app.run(transport="http", host="127.0.0.1", port=8074) ``` ### The fix: Skip the entrypoint file. This means that any tool defined inside of the entrypoint file must be added via MCPApp.add_tool(...) or instead use the recommended @app.tool. --- libs/arcade-core/arcade_core/catalog.py | 1 - libs/arcade-core/arcade_core/toolkit.py | 20 +++++++++ libs/arcade-core/pyproject.toml | 2 +- .../integration/server/pyproject.toml | 44 +++++++++++++++++++ .../integration/server/server.py | 36 --------------- .../integration/server/src/server/__init__.py | 0 .../server/src/server/entrypoint.py | 23 ++++++++++ .../server/{ => src/server}/logging_tools.py | 0 .../server/{ => src/server}/progress_tools.py | 0 .../server/{ => src/server}/sampling_tools.py | 0 .../{ => src/server}/tool_chaining_tools.py | 2 +- .../server}/user_elicitation_tools.py | 0 .../integration/test_end_to_end.py | 39 ++++++++-------- 13 files changed, 110 insertions(+), 57 deletions(-) create mode 100644 libs/tests/arcade_mcp_server/integration/server/pyproject.toml delete mode 100644 libs/tests/arcade_mcp_server/integration/server/server.py create mode 100644 libs/tests/arcade_mcp_server/integration/server/src/server/__init__.py create mode 100644 libs/tests/arcade_mcp_server/integration/server/src/server/entrypoint.py rename libs/tests/arcade_mcp_server/integration/server/{ => src/server}/logging_tools.py (100%) rename libs/tests/arcade_mcp_server/integration/server/{ => src/server}/progress_tools.py (100%) rename libs/tests/arcade_mcp_server/integration/server/{ => src/server}/sampling_tools.py (100%) rename libs/tests/arcade_mcp_server/integration/server/{ => src/server}/tool_chaining_tools.py (86%) rename libs/tests/arcade_mcp_server/integration/server/{ => src/server}/user_elicitation_tools.py (100%) diff --git a/libs/arcade-core/arcade_core/catalog.py b/libs/arcade-core/arcade_core/catalog.py index 2af42384..7852000e 100644 --- a/libs/arcade-core/arcade_core/catalog.py +++ b/libs/arcade-core/arcade_core/catalog.py @@ -283,7 +283,6 @@ class ToolCatalog(BaseModel): module = import_module(module_name) tool_func = getattr(module, tool_name) self.add_tool(tool_func, toolkit, module) - except ToolDefinitionError as e: raise e.with_context(tool_name) from e except ToolkitLoadError as e: diff --git a/libs/arcade-core/arcade_core/toolkit.py b/libs/arcade-core/arcade_core/toolkit.py index 42a5f209..6460a0a3 100644 --- a/libs/arcade-core/arcade_core/toolkit.py +++ b/libs/arcade-core/arcade_core/toolkit.py @@ -1,7 +1,9 @@ +import contextlib import importlib.metadata import importlib.util import logging import os +import sys import types from collections import defaultdict from pathlib import Path, PurePosixPath, PureWindowsPath @@ -293,9 +295,27 @@ class Toolkit(BaseModel): f"Failed to locate Python files in package directory for '{package_name}'." ) from e + # Get the currently executing file (the entrypoint file) so that we can skip it when loading tools. + # Skipping this file is necessary because tools are discovered via AST parsing, but those tools + # aren't in the module's namespace yet since the file is still executing. + current_file = None + main_module = sys.modules.get("__main__") + if main_module and hasattr(main_module, "__file__") and main_module.__file__: + with contextlib.suppress(Exception): + current_file = Path(main_module.__file__).resolve() + tools: dict[str, list[str]] = {} for module_path in modules: + # Skip adding tools from the currently executing file + if current_file: + try: + module_path_resolved = module_path.resolve() + if module_path_resolved == current_file: + continue + except Exception: # noqa: S110 + pass + relative_path = module_path.relative_to(package_dir) cls.validate_file(module_path) # Build import path and avoid duplicating the package prefix if it already exists diff --git a/libs/arcade-core/pyproject.toml b/libs/arcade-core/pyproject.toml index d6aefb65..cd89a593 100644 --- a/libs/arcade-core/pyproject.toml +++ b/libs/arcade-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "arcade-core" -version = "3.3.0" +version = "3.3.1" description = "Arcade Core - Core library for Arcade platform" readme = "README.md" license = {text = "MIT"} diff --git a/libs/tests/arcade_mcp_server/integration/server/pyproject.toml b/libs/tests/arcade_mcp_server/integration/server/pyproject.toml new file mode 100644 index 00000000..02468368 --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/pyproject.toml @@ -0,0 +1,44 @@ +[project] +name = "server" +version = "0.1.0" +description = "MCP Server created with Arcade.dev" +requires-python = ">=3.10" +dependencies = [ + "arcade-mcp-server>=1.5.0,<2.0.0", +] + +[project.optional-dependencies] +dev = [ + "arcade-mcp[all]>=1.4.0,<2.0.0", + "pytest>=7.0.0", + "pytest-asyncio>=0.21.0", + "mypy>=1.0.0", + "ruff>=0.1.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/server"] + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.mypy] +python_version = "3.12" +warn_unused_configs = true +disallow_untyped_defs = false + +# Tell Arcade.dev that this package has Arcade tools +[project.entry-points.arcade_toolkits] +toolkit_name = "server" + +# # Uncomment the following if you are developing inside of the arcade-mcp repo & want to use editable mode +# # Otherwise, you will install the following packages from PyPI +[tool.uv.sources] +arcade-mcp = { path = "../../../../../", editable = true } +arcade-serve = { path = "../../../../arcade-serve/", editable = true } +arcade-mcp-server = { path = "../../../../arcade-mcp-server/", editable = true } diff --git a/libs/tests/arcade_mcp_server/integration/server/server.py b/libs/tests/arcade_mcp_server/integration/server/server.py deleted file mode 100644 index c51832b4..00000000 --- a/libs/tests/arcade_mcp_server/integration/server/server.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env python3 -"""E2E integration test MCP server""" - -import sys - -from arcade_mcp_server import MCPApp -from logging_tools import logging_tool -from progress_tools import reporting_progress -from sampling_tools import sampling -from tool_chaining_tools import ( - call_other_tool, - the_other_tool, -) -from user_elicitation_tools import elicit_nickname - -app = MCPApp(name="Test", version="1.0.0", log_level="DEBUG") - -# Logging -app.add_tool(logging_tool) - -# Report progress -app.add_tool(reporting_progress) - -# Sampling -app.add_tool(sampling) - -# User elicitation -app.add_tool(elicit_nickname) - -# Tool chaining -app.add_tool(call_other_tool) -app.add_tool(the_other_tool) - -if __name__ == "__main__": - transport = sys.argv[1] if len(sys.argv) > 1 else "http" - app.run(transport=transport, host="127.0.0.1", port=8000) diff --git a/libs/tests/arcade_mcp_server/integration/server/src/server/__init__.py b/libs/tests/arcade_mcp_server/integration/server/src/server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libs/tests/arcade_mcp_server/integration/server/src/server/entrypoint.py b/libs/tests/arcade_mcp_server/integration/server/src/server/entrypoint.py new file mode 100644 index 00000000..207c4192 --- /dev/null +++ b/libs/tests/arcade_mcp_server/integration/server/src/server/entrypoint.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python3 +"""E2E integration test MCP server""" + +import sys +from typing import Annotated + +from arcade_mcp_server import MCPApp + +import server + +app = MCPApp(name="server", version="1.0.0", log_level="DEBUG") +app.add_tools_from_module(server) + + +@app.tool +def hello_world(name: Annotated[str, "The name to say hello to"]) -> str: + """Say hello to the given name.""" + return f"Hello, {name}!" + + +if __name__ == "__main__": + transport = sys.argv[1] if len(sys.argv) > 1 else "http" + app.run(transport=transport, host="127.0.0.1", port=8000) diff --git a/libs/tests/arcade_mcp_server/integration/server/logging_tools.py b/libs/tests/arcade_mcp_server/integration/server/src/server/logging_tools.py similarity index 100% rename from libs/tests/arcade_mcp_server/integration/server/logging_tools.py rename to libs/tests/arcade_mcp_server/integration/server/src/server/logging_tools.py diff --git a/libs/tests/arcade_mcp_server/integration/server/progress_tools.py b/libs/tests/arcade_mcp_server/integration/server/src/server/progress_tools.py similarity index 100% rename from libs/tests/arcade_mcp_server/integration/server/progress_tools.py rename to libs/tests/arcade_mcp_server/integration/server/src/server/progress_tools.py diff --git a/libs/tests/arcade_mcp_server/integration/server/sampling_tools.py b/libs/tests/arcade_mcp_server/integration/server/src/server/sampling_tools.py similarity index 100% rename from libs/tests/arcade_mcp_server/integration/server/sampling_tools.py rename to libs/tests/arcade_mcp_server/integration/server/src/server/sampling_tools.py diff --git a/libs/tests/arcade_mcp_server/integration/server/tool_chaining_tools.py b/libs/tests/arcade_mcp_server/integration/server/src/server/tool_chaining_tools.py similarity index 86% rename from libs/tests/arcade_mcp_server/integration/server/tool_chaining_tools.py rename to libs/tests/arcade_mcp_server/integration/server/src/server/tool_chaining_tools.py index 6a33ef9d..3d6903af 100644 --- a/libs/tests/arcade_mcp_server/integration/server/tool_chaining_tools.py +++ b/libs/tests/arcade_mcp_server/integration/server/src/server/tool_chaining_tools.py @@ -13,7 +13,7 @@ async def call_other_tool( ) -> str: """Get the hash value of a secret""" - other_tool_response = await context.tools.call_raw("Test_TheOtherTool", {}) + other_tool_response = await context.tools.call_raw("Server_TheOtherTool", {}) if other_tool_response.isError: return ( diff --git a/libs/tests/arcade_mcp_server/integration/server/user_elicitation_tools.py b/libs/tests/arcade_mcp_server/integration/server/src/server/user_elicitation_tools.py similarity index 100% rename from libs/tests/arcade_mcp_server/integration/server/user_elicitation_tools.py rename to libs/tests/arcade_mcp_server/integration/server/src/server/user_elicitation_tools.py diff --git a/libs/tests/arcade_mcp_server/integration/test_end_to_end.py b/libs/tests/arcade_mcp_server/integration/test_end_to_end.py index 5c1759ba..befc689a 100644 --- a/libs/tests/arcade_mcp_server/integration/test_end_to_end.py +++ b/libs/tests/arcade_mcp_server/integration/test_end_to_end.py @@ -9,7 +9,6 @@ import json import os import random import subprocess -import sys import time from pathlib import Path from typing import Any @@ -20,9 +19,9 @@ import pytest # Helper Functions -def get_server_path() -> str: +def get_entrypoint_path() -> str: """Get the path to the test server entrypoint.""" - return str(Path(__file__).parent / "server" / "server.py") + return str(Path(__file__).parent / "server" / "src" / "server" / "entrypoint.py") def start_mcp_server( @@ -38,10 +37,12 @@ def start_mcp_server( Returns: Tuple of (process, port). Port is None for stdio transport. """ - server_path = get_server_path() + entrypoint_path = get_entrypoint_path() + # Get the server package directory (where pyproject.toml is) + package_path = Path(__file__).parent / "server" if transport == "stdio": - cmd = [sys.executable, server_path, "stdio"] + cmd = ["uv", "run", entrypoint_path, "stdio"] process = subprocess.Popen( cmd, stdin=subprocess.PIPE, @@ -49,6 +50,7 @@ def start_mcp_server( stderr=subprocess.PIPE, text=True, bufsize=1, # Line buffered + cwd=str(package_path), ) return process, None @@ -64,13 +66,14 @@ def start_mcp_server( "ARCADE_AUTH_DISABLED": "true", } - cmd = [sys.executable, server_path, "http"] + cmd = ["uv", "run", entrypoint_path, "http"] process = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, + cwd=str(package_path), ) return process, port @@ -298,7 +301,7 @@ async def test_stdio_e2e(): assert init_response["id"] == init_id assert "result" in init_response assert "error" not in init_response - assert init_response["result"]["serverInfo"]["name"] == "Test" + assert init_response["result"]["serverInfo"]["name"] == "server" assert init_response["result"]["serverInfo"]["version"] == "1.0.0" # 2. Send initialized notification @@ -319,13 +322,13 @@ async def test_stdio_e2e(): assert "result" in list_tools_response assert "tools" in list_tools_response["result"] tools = list_tools_response["result"]["tools"] - assert len(tools) == 6 + assert len(tools) == 7 # 5. Call logging_tool logging_id = client.send_request( "tools/call", { - "name": "Test_LoggingTool", + "name": "Server_LoggingTool", "arguments": {"message": "test message"}, }, ) @@ -351,7 +354,7 @@ async def test_stdio_e2e(): progress_id = client.send_request( "tools/call", { - "name": "Test_ReportingProgress", + "name": "Server_ReportingProgress", "arguments": {}, "_meta": { "progressToken": "test-progress-token", @@ -388,7 +391,7 @@ async def test_stdio_e2e(): chaining_id = client.send_request( "tools/call", { - "name": "Test_CallOtherTool", + "name": "Server_CallOtherTool", "arguments": {}, }, ) @@ -402,7 +405,7 @@ async def test_stdio_e2e(): sampling_id = client.send_request( "tools/call", { - "name": "Test_Sampling", + "name": "Server_Sampling", "arguments": {"text": "This is some text to summarize."}, }, ) @@ -442,7 +445,7 @@ async def test_stdio_e2e(): elicit_id = client.send_request( "tools/call", { - "name": "Test_ElicitNickname", + "name": "Server_ElicitNickname", "arguments": {}, }, ) @@ -520,7 +523,7 @@ async def test_http_e2e(): assert init_data["id"] == 1 assert "result" in init_data assert "error" not in init_data - assert init_data["result"]["serverInfo"]["name"] == "Test" + assert init_data["result"]["serverInfo"]["name"] == "server" assert init_data["result"]["serverInfo"]["version"] == "1.0.0" session_id = init_response.headers.get("mcp-session-id") @@ -555,13 +558,13 @@ async def test_http_e2e(): assert "result" in list_tools_data assert "tools" in list_tools_data["result"] tools = list_tools_data["result"]["tools"] - assert len(tools) == 6 + assert len(tools) == 7 # 5. Call logging_tool logging_request = build_jsonrpc_request( "tools/call", { - "name": "Test_LoggingTool", + "name": "Server_LoggingTool", "arguments": {"message": "test message"}, }, request_id=4, @@ -580,7 +583,7 @@ async def test_http_e2e(): progress_request = build_jsonrpc_request( "tools/call", { - "name": "Test_ReportingProgress", + "name": "Server_ReportingProgress", "arguments": {}, }, request_id=5, @@ -598,7 +601,7 @@ async def test_http_e2e(): chaining_request = build_jsonrpc_request( "tools/call", { - "name": "Test_CallOtherTool", + "name": "Server_CallOtherTool", "arguments": {}, }, request_id=6,