diff --git a/libs/arcade-core/arcade_core/catalog.py b/libs/arcade-core/arcade_core/catalog.py index 2af42384c..7852000e0 100644 --- a/libs/arcade-core/arcade_core/catalog.py +++ b/libs/arcade-core/arcade_core/catalog.py @@ -283,7 +283,6 @@ def add_toolkit(self, toolkit: Toolkit) -> None: 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 42a5f2098..6460a0a35 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 @@ def tools_from_directory(cls, package_dir: Path, package_name: str) -> dict[str, 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 d6aefb654..cd89a5937 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 000000000..024683685 --- /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 c51832b41..000000000 --- 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 000000000..e69de29bb 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 000000000..207c41920 --- /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 6a33ef9d3..3d6903af9 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 5c1759bac..befc689a4 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 os import random import subprocess -import sys import time from pathlib import Path from typing import Any @@ -20,9 +19,9 @@ # 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,