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:
Eric Gustin 2025-11-03 11:26:51 -08:00 committed by GitHub
parent 4ca824cf8f
commit d89b3a53d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 110 additions and 57 deletions

View file

@ -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:

View file

@ -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

View file

@ -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"}

View file

@ -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 }

View file

@ -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)

View file

@ -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)

View file

@ -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 (

View file

@ -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,