Fix: Skip currently executing file during tool discovery (#668)
### 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.
This commit is contained in:
parent
4ca824cf8f
commit
d89b3a53d4
13 changed files with 110 additions and 57 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 (
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue