arcade-mcp/arcade/tests/worker/test_worker_base.py
Sam Partee 9bc1cd4a12
Support for MCP stdio transport (#368)
MCP stdio Implementation:

The PR adds support for standard input/output (stdio) as a transport
mechanism for the Message Control Protocol. This is a replacement to the
SSE (Server-Sent Events) transport that was worked on in PR #359 but
will not be merged as it's not deprecated.

This will allow developers to use Arcade tools (written by the dev or
Arcade) in Claude, Cursor, windsurf, etc.

The engine Gateway already supports adding HTTPS streamable (replacement
for SSE) MCP servers as tool servers, and will soon support full gateway
capability in the client API as well.

To use any existing Toolkit just 

## Examples

### Quickstart setup with existing toolkits

```bash
pip install arcade-ai
pip install <name of toolkit> # ex. arcade-google
arcade serve --mcp
```

### Run with Claude

Just add the following to the Claude config

```json
{
  "mcpServers": {
    "arcade": {
      "command": "bash",
      "args": ["-c", "export ARCADE_API_KEY=arc_xxxx && /path/to/python /path/to/arcade serve --mcp"]
    }
  }
}
```

### Customizing the Tool Server

Developers can customize their served tools and server furthermore by
importing the worker sdk

```python

import arcade_google  # pip install arcade_google
import arcade_search  # pip install arcade_search

from arcade.core.catalog import ToolCatalog
from arcade.worker.mcp.stdio import StdioServer

# 2. Create and populate the tool catalog
catalog = ToolCatalog()
catalog.add_module(arcade_google)  # Registers all tools in the package
catalog.add_module(arcade_search)


# 3. Main entrypoint
async def main():
    # Create the worker with the tool catalog
    worker = StdioServer(catalog)

    # Run the worker
    await worker.run()


if __name__ == "__main__":
    import asyncio

    asyncio.run(main())
    
 ```
 
 Then to run with claude, just run this python file instead of the prebuilt server used in ``arcade serve --mcp``
2025-05-02 06:27:43 -07:00

237 lines
7.6 KiB
Python

import os
from typing import Annotated
from unittest.mock import MagicMock
import pytest
from arcade.core.errors import ToolDefinitionError
from arcade.core.schema import (
ToolCallRequest,
ToolCallResponse,
ToolContext,
ToolReference,
)
from arcade.sdk import tool
from arcade.worker.core.base import BaseWorker
from arcade.worker.core.common import RequestData, Router
from arcade.worker.core.components import (
CallToolComponent,
CatalogComponent,
HealthCheckComponent,
)
@tool()
def sample_tool(
context: ToolContext, a: Annotated[int, "a"], b: Annotated[int, "b"]
) -> Annotated[int, "output"]:
"""Sample tool for testing."""
return a + b
# Define error tool at module level to avoid indentation issues with getsource
@tool()
def error_tool(context: ToolContext) -> int:
"""This tool always raises an error."""
raise ValueError("Something went wrong")
@pytest.fixture
def mock_router():
router = MagicMock(spec=Router)
router.add_route = MagicMock()
return router
@pytest.fixture
def base_worker(mock_router):
# Set env var temporarily for testing secret loading
os.environ["ARCADE_WORKER_SECRET"] = "test_secret_env" # noqa: S105
worker = BaseWorker()
worker.register_routes(mock_router) # Register routes using the mock router
# Clean up env var
del os.environ["ARCADE_WORKER_SECRET"]
return worker
@pytest.fixture
def base_worker_no_auth():
return BaseWorker(disable_auth=True)
# --- BaseWorker Tests ---
def test_base_worker_init_with_secret():
worker = BaseWorker(secret="explicit_secret") # noqa: S106
assert worker.secret == "explicit_secret" # noqa: S105
assert not worker.disable_auth
def test_base_worker_init_with_env_secret():
os.environ["ARCADE_WORKER_SECRET"] = "env_secret_value" # noqa: S105
worker = BaseWorker()
assert worker.secret == "env_secret_value" # noqa: S105
assert not worker.disable_auth
del os.environ["ARCADE_WORKER_SECRET"]
def test_base_worker_init_no_secret_raises_error():
# Ensure env var is not set
if "ARCADE_WORKER_SECRET" in os.environ:
del os.environ["ARCADE_WORKER_SECRET"]
with pytest.raises(ValueError, match="No secret provided for worker"):
BaseWorker()
def test_base_worker_init_disable_auth():
worker = BaseWorker(disable_auth=True)
assert worker.secret == ""
assert worker.disable_auth
def test_register_tool(base_worker_no_auth):
assert len(base_worker_no_auth.catalog) == 0
base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
assert len(base_worker_no_auth.catalog) == 1
tool_def = base_worker_no_auth.get_catalog()[0]
assert tool_def.name == "SampleTool"
assert tool_def.toolkit.name == "TestKit"
def test_get_catalog(base_worker_no_auth):
base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
catalog = base_worker_no_auth.get_catalog()
assert isinstance(catalog, list)
assert len(catalog) == 1
assert catalog[0].name == "SampleTool"
def test_health_check(base_worker_no_auth):
base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
health = base_worker_no_auth.health_check()
assert health == {"status": "ok", "tool_count": "1"}
@pytest.mark.asyncio
async def test_call_tool_success(base_worker_no_auth):
base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
# Create ToolReference WITHOUT version, as register_tool doesn't seem to set it
tool_ref = ToolReference(toolkit="TestKit", name="SampleTool")
tool_request = ToolCallRequest(
execution_id="test_exec_id",
tool=tool_ref,
inputs={"a": 5, "b": 3},
)
response = await base_worker_no_auth.call_tool(tool_request)
assert response.success is True
assert response.output.value == 8
assert response.output.error is None
assert response.execution_id == "test_exec_id"
assert response.duration > 0
@pytest.mark.asyncio
async def test_call_tool_execution_error(base_worker_no_auth):
# Tool is now defined at module level
try:
base_worker_no_auth.register_tool(error_tool, toolkit_name="error_kit")
except ToolDefinitionError as e:
pytest.fail(f"Failed to register error_tool: {e}")
# Create ToolReference WITHOUT version
tool_ref = ToolReference(toolkit="ErrorKit", name="ErrorTool")
tool_request = ToolCallRequest(
execution_id="test_exec_error",
tool=tool_ref,
inputs={},
)
response = await base_worker_no_auth.call_tool(tool_request)
assert response.success is False
assert response.output.value is None
assert response.output.error is not None
@pytest.mark.asyncio
async def test_call_tool_not_found(base_worker_no_auth):
# Use ToolReference without version for lookup consistency
tool_ref = ToolReference(toolkit="nonexistent", name="nosuchtool")
tool_request = ToolCallRequest(
execution_id="test_exec_notfound",
tool=tool_ref,
inputs={},
)
# Update regex to match actual error format
with pytest.raises(ValueError):
await base_worker_no_auth.call_tool(tool_request)
# --- Component Tests (tested via BaseWorker registration) ---
def test_register_routes_registers_default_components(base_worker, mock_router):
# BaseWorker calls register_routes in its init via the fixture
assert mock_router.add_route.call_count == len(BaseWorker.default_components)
calls = mock_router.add_route.call_args_list
expected_paths = ["tools", "tools/invoke", "health"]
registered_paths = [
call[0][0] for call in calls
] # call[0] are positional args, call[0][0] is endpoint_path
assert sorted(registered_paths) == sorted(expected_paths)
# Check if components were instantiated and passed to add_route
assert any(isinstance(call[0][1], CatalogComponent) for call in calls)
assert any(isinstance(call[0][1], CallToolComponent) for call in calls)
assert any(isinstance(call[0][1], HealthCheckComponent) for call in calls)
@pytest.mark.asyncio
async def test_catalog_component_call(base_worker_no_auth):
base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
component = CatalogComponent(base_worker_no_auth)
# Mock request data - not actually used by this component's __call__
mock_request = MagicMock(spec=RequestData)
catalog_response = await component(mock_request)
assert isinstance(catalog_response, list)
assert len(catalog_response) == 1
assert catalog_response[0].name == "SampleTool"
@pytest.mark.asyncio
async def test_call_tool_component_call(base_worker_no_auth):
base_worker_no_auth.register_tool(sample_tool, toolkit_name="test_kit")
component = CallToolComponent(base_worker_no_auth)
# Create ToolReference WITHOUT version
tool_ref = ToolReference(toolkit="TestKit", name="SampleTool")
request_body = {
"execution_id": "comp_test_exec",
"tool": tool_ref.model_dump(),
"inputs": {"a": 10, "b": 5},
}
mock_request = MagicMock(spec=RequestData)
mock_request.body_json = request_body
response = await component(mock_request)
assert isinstance(response, ToolCallResponse)
assert response.success is True
assert response.output.value == 15
assert response.execution_id == "comp_test_exec"
@pytest.mark.asyncio
async def test_health_check_component_call(base_worker_no_auth):
component = HealthCheckComponent(base_worker_no_auth)
mock_request = MagicMock(spec=RequestData)
health_response = await component(mock_request)
assert health_response == {"status": "ok", "tool_count": "0"}