Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion libs/arcade-core/arcade_core/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions libs/arcade-core/arcade_core/toolkit.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion libs/arcade-core/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"}
Expand Down
44 changes: 44 additions & 0 deletions libs/tests/arcade_mcp_server/integration/server/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 }
36 changes: 0 additions & 36 deletions libs/tests/arcade_mcp_server/integration/server/server.py

This file was deleted.

Empty file.
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
39 changes: 21 additions & 18 deletions libs/tests/arcade_mcp_server/integration/test_end_to_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import os
import random
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
Expand All @@ -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(
Expand All @@ -38,17 +37,20 @@ 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,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1, # Line buffered
cwd=str(package_path),
)
return process, None

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

Expand Down Expand Up @@ -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
Expand All @@ -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"},
},
)
Expand All @@ -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",
Expand Down Expand Up @@ -388,7 +391,7 @@ async def test_stdio_e2e():
chaining_id = client.send_request(
"tools/call",
{
"name": "Test_CallOtherTool",
"name": "Server_CallOtherTool",
"arguments": {},
},
)
Expand All @@ -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."},
},
)
Expand Down Expand Up @@ -442,7 +445,7 @@ async def test_stdio_e2e():
elicit_id = client.send_request(
"tools/call",
{
"name": "Test_ElicitNickname",
"name": "Server_ElicitNickname",
"arguments": {},
},
)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down