We recently added outputSchema support for our MCP tools (not yet for
worker routes yet). Today, we always return structuredContent. On tool
execution errors we return structuredContent: {"error": "..."} with
isError: True, even when that shape does not match the tool’s declared
outputSchema. Since the MCP spec says clients SHOULD validate
structuredContent against outputSchema, some clients reject these
responses.
Since structuredContent is optional, we’re going to omit it when
isError: true.
<!-- CURSOR_SUMMARY -->
---
> [!NOTE]
> **Medium Risk**
> Changes the shape of tool error responses across the MCP server, which
may break clients or tools that previously relied on
`structuredContent["error"]` for failures. Behavior is more
spec-compliant but touches core request/response paths and test
expectations.
>
> **Overview**
> Prevents MCP tool error responses from violating a tool’s declared
`outputSchema` by **always setting `structuredContent=None` when
`isError=True`** (server execution errors, unknown tools, middleware
exceptions, and `Context.tools.call_raw` JSON-RPC errors).
>
> Updates requirement-failure error formatting to put the human-friendly
message in `content[0]` and (when present) serialize extra
machine-readable fields (e.g. `authorization_url`, `llm_instructions`)
into an additional `content` item. Examples and integration/unit tests
are updated to read errors from `content[0].text`, and
`arcade-mcp-server` is bumped to `1.19.2`.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
4213bdd4aa44362de85c30f5f31c576243c132d5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
1801 lines
65 KiB
Python
1801 lines
65 KiB
Python
"""Tests for MCP Server implementation."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
from typing import Annotated
|
|
from unittest.mock import AsyncMock, Mock
|
|
|
|
import pytest
|
|
from arcade_core.auth import OAuth2
|
|
from arcade_core.catalog import MaterializedTool, ToolMeta, create_func_models
|
|
from arcade_core.errors import ToolRuntimeError
|
|
from arcade_core.schema import (
|
|
InputParameter,
|
|
OAuth2Requirement,
|
|
ToolAuthRequirement,
|
|
ToolContext,
|
|
ToolDefinition,
|
|
ToolInput,
|
|
ToolkitDefinition,
|
|
ToolOutput,
|
|
ToolRequirements,
|
|
ToolSecretRequirement,
|
|
ValueSchema,
|
|
)
|
|
from arcade_mcp_server import tool
|
|
from arcade_mcp_server.middleware import Middleware
|
|
from arcade_mcp_server.server import MCPServer
|
|
from arcade_mcp_server.session import InitializationState
|
|
from arcade_mcp_server.types import (
|
|
CallToolRequest,
|
|
CallToolResult,
|
|
InitializeRequest,
|
|
InitializeResult,
|
|
JSONRPCError,
|
|
JSONRPCResponse,
|
|
ListToolsRequest,
|
|
ListToolsResult,
|
|
PingRequest,
|
|
)
|
|
|
|
|
|
class TestMCPServer:
|
|
"""Test MCPServer class."""
|
|
|
|
def test_server_initialization(self, tool_catalog, mcp_settings):
|
|
"""Test server initialization with various configurations."""
|
|
# Basic initialization
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="Test Server",
|
|
version="1.9.0",
|
|
settings=mcp_settings,
|
|
)
|
|
|
|
assert server.name == "Test Server"
|
|
assert server.version == "1.9.0"
|
|
assert server.title == "Test Server"
|
|
assert server.settings == mcp_settings
|
|
|
|
# With custom title and instructions
|
|
server2 = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="Test Server",
|
|
version="1.0.0",
|
|
title="Custom Title",
|
|
instructions="Custom instructions",
|
|
)
|
|
|
|
assert server2.title == "Custom Title"
|
|
assert server2.instructions == "Custom instructions"
|
|
|
|
def test_server_initialization_with_settings_defaults(self, tool_catalog):
|
|
"""Test server initialization uses settings when parameters not provided."""
|
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
|
|
|
settings = MCPSettings(
|
|
server=ServerSettings(
|
|
name="SettingsName",
|
|
version="2.0.0",
|
|
title="SettingsTitle",
|
|
instructions="Settings instructions",
|
|
)
|
|
)
|
|
|
|
# Initialize without name/version - should use settings
|
|
server = MCPServer(catalog=tool_catalog, settings=settings)
|
|
|
|
assert server.name == "SettingsName"
|
|
assert server.version == "2.0.0"
|
|
assert server.title == "SettingsTitle"
|
|
assert server.instructions == "Settings instructions"
|
|
|
|
def test_server_initialization_parameters_override_settings(self, tool_catalog):
|
|
"""Test server initialization parameters override settings."""
|
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
|
|
|
settings = MCPSettings(
|
|
server=ServerSettings(
|
|
name="SettingsName",
|
|
version="2.0.0",
|
|
title="SettingsTitle",
|
|
instructions="Settings instructions",
|
|
)
|
|
)
|
|
|
|
# Initialize with explicit parameters (should override settings)
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="ParamName",
|
|
version="3.0.0",
|
|
title="ParamTitle",
|
|
instructions="Param instructions",
|
|
settings=settings,
|
|
)
|
|
|
|
assert server.name == "ParamName"
|
|
assert server.version == "3.0.0"
|
|
assert server.title == "ParamTitle"
|
|
assert server.instructions == "Param instructions"
|
|
|
|
def test_server_initialization_title_fallback_logic(self, tool_catalog):
|
|
"""Test server initialization title fallback logic."""
|
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
|
|
|
# Test 1: Title parameter provided (should be used)
|
|
server1 = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="TestServer",
|
|
title="ExplicitTitle",
|
|
)
|
|
assert server1.title == "ExplicitTitle"
|
|
|
|
# Test 2: No title parameter but settings has non-default title
|
|
settings2 = MCPSettings(
|
|
server=ServerSettings(
|
|
name="SettingsServer",
|
|
title="CustomSettingsTitle",
|
|
)
|
|
)
|
|
server2 = MCPServer(catalog=tool_catalog, settings=settings2)
|
|
assert server2.title == "CustomSettingsTitle"
|
|
|
|
# Test 3: No title parameter, settings has default title (should use name)
|
|
settings3 = MCPSettings(
|
|
server=ServerSettings(
|
|
name="SettingsServer",
|
|
title="ArcadeMCP", # Default value
|
|
)
|
|
)
|
|
server3 = MCPServer(catalog=tool_catalog, settings=settings3)
|
|
assert server3.title == "SettingsServer"
|
|
|
|
# Test 4: No title parameter, no settings title (should use name)
|
|
settings4 = MCPSettings(
|
|
server=ServerSettings(
|
|
name="SettingsServer",
|
|
title=None,
|
|
)
|
|
)
|
|
server4 = MCPServer(catalog=tool_catalog, settings=settings4)
|
|
assert server4.title == "SettingsServer"
|
|
|
|
def test_server_initialization_instructions_fallback(self, tool_catalog):
|
|
"""Test server initialization instructions fallback logic."""
|
|
from arcade_mcp_server.settings import MCPSettings, ServerSettings
|
|
|
|
# Test 1: Instructions parameter provided (should be used)
|
|
server1 = MCPServer(
|
|
catalog=tool_catalog,
|
|
instructions="Explicit instructions",
|
|
)
|
|
assert server1.instructions == "Explicit instructions"
|
|
|
|
# Test 2: No instructions parameter (should use settings)
|
|
settings2 = MCPSettings(
|
|
server=ServerSettings(
|
|
instructions="Settings instructions",
|
|
)
|
|
)
|
|
server2 = MCPServer(catalog=tool_catalog, settings=settings2)
|
|
assert server2.instructions == "Settings instructions"
|
|
|
|
# Test 3: No instructions parameter, no settings (should use default)
|
|
settings3 = MCPSettings(
|
|
server=ServerSettings(
|
|
instructions=None,
|
|
)
|
|
)
|
|
server3 = MCPServer(catalog=tool_catalog, settings=settings3)
|
|
assert "available tools" in server3.instructions.lower()
|
|
|
|
def test_handler_registration(self, tool_catalog):
|
|
"""Test that all required handlers are registered."""
|
|
server = MCPServer(catalog=tool_catalog)
|
|
|
|
expected_handlers = [
|
|
"ping",
|
|
"initialize",
|
|
"tools/list",
|
|
"tools/call",
|
|
"resources/list",
|
|
"resources/templates/list",
|
|
"resources/read",
|
|
"prompts/list",
|
|
"prompts/get",
|
|
"logging/setLevel",
|
|
]
|
|
|
|
for method in expected_handlers:
|
|
assert method in server._handlers
|
|
assert callable(server._handlers[method])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_lifecycle(self, tool_catalog, mcp_settings):
|
|
"""Test server startup and shutdown."""
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
)
|
|
|
|
# Start server
|
|
await server.start()
|
|
|
|
# Stop server
|
|
await server.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_ping(self, mcp_server):
|
|
"""Test ping request handling."""
|
|
message = PingRequest(jsonrpc="2.0", id=1, method="ping")
|
|
|
|
response = await mcp_server._handle_ping(message)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert response.id == 1
|
|
assert response.result == {}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_initialize(self, mcp_server):
|
|
"""Test initialize request handling."""
|
|
message = InitializeRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="initialize",
|
|
params={
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "test-client", "version": "1.0.0"},
|
|
},
|
|
)
|
|
|
|
# Create mock session
|
|
session = Mock()
|
|
session.set_client_params = Mock()
|
|
|
|
response = await mcp_server._handle_initialize(message, session=session)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert response.id == 1
|
|
assert isinstance(response.result, InitializeResult)
|
|
assert response.result.protocolVersion is not None
|
|
assert response.result.serverInfo.name == mcp_server.name
|
|
assert response.result.serverInfo.version == mcp_server.version
|
|
|
|
# Check session was updated
|
|
session.set_client_params.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_list_tools(self, mcp_server):
|
|
"""Test list tools request handling."""
|
|
message = ListToolsRequest(jsonrpc="2.0", id=2, method="tools/list", params={})
|
|
|
|
response = await mcp_server._handle_list_tools(message)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert response.id == 2
|
|
assert isinstance(response.result, ListToolsResult)
|
|
assert len(response.result.tools) > 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_call_tool(self, mcp_server):
|
|
"""Test tool call request handling."""
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=3,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.test_tool", "arguments": {"text": "Hello"}},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert response.id == 3
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.structuredContent is not None
|
|
assert "result" in response.result.structuredContent
|
|
assert "Echo: Hello" in response.result.structuredContent["result"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_call_tool_with_requires_auth(self, mcp_server):
|
|
"""Test tool call request handling with authorization."""
|
|
|
|
# Mock arcade client so the server thinks API key is configured
|
|
mock_arcade = Mock()
|
|
mcp_server.arcade = mock_arcade
|
|
|
|
mock_auth_response = Mock()
|
|
mock_auth_response.status = "pending"
|
|
mock_auth_response.url = "https://example.com/auth"
|
|
|
|
# Patch the _check_authorization method to return a tool that has unsatisfied authorization
|
|
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=3,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.sample_tool_with_auth", "arguments": {"text": "Hello"}},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert response.id == 3
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.structuredContent is None
|
|
content_text = response.result.content[0].text
|
|
assert "Authorization required" in content_text
|
|
assert "needs your permission" in content_text
|
|
# The authorization URL is included in the human-readable message
|
|
assert "https://example.com/auth" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_call_tool_with_requires_auth_no_api_key(self, mcp_server):
|
|
"""Test tool call request handling with authorization when no Arcade API key is configured."""
|
|
|
|
# Ensure no arcade client is configured
|
|
mcp_server.arcade = None
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=3,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.sample_tool_with_auth", "arguments": {"text": "Hello"}},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert response.id == 3
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.structuredContent is None
|
|
content_text = response.result.content[0].text
|
|
assert "Missing Arcade API key" in content_text
|
|
assert "requires authorization" in content_text
|
|
assert "arcade login" in content_text
|
|
assert "ARCADE_API_KEY" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_call_tool_not_found(self, mcp_server):
|
|
"""Test calling a non-existent tool."""
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=3,
|
|
method="tools/call",
|
|
params={"name": "NonExistent.tool", "arguments": {}},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert response.result.isError
|
|
assert response.result.structuredContent is None
|
|
assert "Unknown tool" in response.result.content[0].text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_message_routing(self, mcp_server, initialized_server_session):
|
|
"""Test message routing to appropriate handlers."""
|
|
# Test valid method
|
|
message = {"jsonrpc": "2.0", "id": 1, "method": "ping"}
|
|
|
|
response = await mcp_server.handle_message(message, session=initialized_server_session)
|
|
|
|
assert response is not None
|
|
assert str(response.id) == "1"
|
|
assert response.result == {}
|
|
|
|
# Test invalid method
|
|
message = {"jsonrpc": "2.0", "id": 2, "method": "invalid/method"}
|
|
|
|
response = await mcp_server.handle_message(message, session=initialized_server_session)
|
|
|
|
assert isinstance(response, JSONRPCError)
|
|
assert response.error["code"] == -32601
|
|
assert "Method not found" in response.error["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_message_invalid_format(self, mcp_server):
|
|
"""Test handling of invalid message formats."""
|
|
# Non-dict message
|
|
response = await mcp_server.handle_message("invalid", session=None)
|
|
|
|
assert isinstance(response, JSONRPCError)
|
|
assert response.error["code"] == -32600
|
|
assert "Invalid request" in response.error["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialization_state_enforcement(self, mcp_server):
|
|
"""Test that non-initialize methods are blocked before initialization."""
|
|
# Create uninitialized session
|
|
session = Mock()
|
|
session.initialization_state = InitializationState.NOT_INITIALIZED
|
|
|
|
# Try to call tools/list before initialization
|
|
message = {"jsonrpc": "2.0", "id": 1, "method": "tools/list"}
|
|
|
|
response = await mcp_server.handle_message(message, session=session)
|
|
|
|
assert isinstance(response, JSONRPCError)
|
|
assert response.error["code"] == -32600
|
|
assert "Not initialized" in response.error["message"]
|
|
assert "cannot be processed before the session is initialized" in response.error["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notification_handling(self, mcp_server):
|
|
"""Test handling of notification messages."""
|
|
session = Mock()
|
|
session.mark_initialized = Mock()
|
|
|
|
# Send initialized notification
|
|
message = {"jsonrpc": "2.0", "method": "notifications/initialized"}
|
|
|
|
response = await mcp_server.handle_message(message, session=session)
|
|
|
|
# Notifications should not return a response
|
|
assert response is None
|
|
# Session should be marked as initialized
|
|
session.mark_initialized.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_middleware_chain(self, tool_catalog, mcp_settings):
|
|
"""Test middleware chain execution."""
|
|
# Create a test middleware
|
|
test_middleware_called = False
|
|
|
|
class TestMiddleware(Middleware):
|
|
async def __call__(self, context, call_next):
|
|
nonlocal test_middleware_called
|
|
test_middleware_called = True
|
|
# Modify context
|
|
context.metadata["test"] = "value"
|
|
return await call_next(context)
|
|
|
|
# Create server with middleware
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
middleware=[TestMiddleware()],
|
|
)
|
|
await server.start()
|
|
|
|
# Send a message
|
|
message = {"jsonrpc": "2.0", "id": 1, "method": "ping"}
|
|
|
|
response = await server.handle_message(message)
|
|
|
|
# Middleware should have been called
|
|
assert test_middleware_called
|
|
assert response is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_handling_middleware(self, mcp_server):
|
|
"""Test that error handling middleware catches exceptions."""
|
|
|
|
# Mock a handler to raise an exception
|
|
async def failing_handler(*args, **kwargs):
|
|
raise Exception("Test error")
|
|
|
|
mcp_server._handlers["test/fail"] = failing_handler
|
|
|
|
message = {"jsonrpc": "2.0", "id": 1, "method": "test/fail"}
|
|
|
|
response = await mcp_server.handle_message(message)
|
|
|
|
assert isinstance(response, JSONRPCError)
|
|
assert response.error["code"] == -32603
|
|
# Error details should be masked in production
|
|
if mcp_server.settings.middleware.mask_error_details:
|
|
assert response.error["message"] == "Internal error"
|
|
else:
|
|
assert "Test error" in response.error["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_management(self, mcp_server):
|
|
"""Test session creation and cleanup."""
|
|
|
|
# Create a mock read stream that waits
|
|
async def mock_stream():
|
|
try:
|
|
while True:
|
|
await asyncio.sleep(1) # Keep the session alive
|
|
yield None # Yield nothing
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
mock_read_stream = mock_stream()
|
|
mock_write_stream = AsyncMock()
|
|
|
|
# Track sessions
|
|
initial_sessions = len(mcp_server._sessions)
|
|
|
|
# Create a new connection
|
|
session_task = asyncio.create_task(
|
|
mcp_server.run_connection(mock_read_stream, mock_write_stream)
|
|
)
|
|
|
|
# Give it time to register
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Should have one more session
|
|
assert len(mcp_server._sessions) == initial_sessions + 1
|
|
|
|
# Cancel the session
|
|
session_task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await session_task
|
|
|
|
# Give it time to clean up
|
|
await asyncio.sleep(0.1)
|
|
|
|
# Session should be cleaned up
|
|
assert len(mcp_server._sessions) == initial_sessions
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authorization_check(self, mcp_server):
|
|
"""Test tool authorization checking."""
|
|
|
|
# Ensure the arcade client is not configured in the case that the test environment
|
|
# unintentionally has the ARCADE_API_KEY set
|
|
mcp_server.arcade = None
|
|
|
|
tool = Mock()
|
|
tool.definition.requirements.authorization = ToolAuthRequirement(
|
|
provider_type="oauth2", provider_id="test-provider"
|
|
)
|
|
|
|
# Without arcade client configured
|
|
with pytest.raises(Exception) as exc_info:
|
|
await mcp_server._check_authorization(tool)
|
|
|
|
assert "Authorization check called without Arcade API key configured" in str(exc_info.value)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_no_requirements(self, mcp_server, materialized_tool):
|
|
"""Test tool requirements checking when tool has no requirements."""
|
|
|
|
# Create a tool with no requirements
|
|
tool = materialized_tool
|
|
tool.definition.requirements = None
|
|
|
|
tool_context = ToolContext()
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.test_tool", "arguments": {"text": "Hello"}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.test_tool"
|
|
)
|
|
|
|
# Should return None when no requirements because this means the tool can be executed
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_auth_no_arcade_client(self, mcp_server):
|
|
"""Test tool requirements checking when tool requires auth but no Arcade client configured."""
|
|
|
|
# Ensure no arcade client is configured
|
|
mcp_server.arcade = None
|
|
|
|
# Create a tool that requires authorization
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
authorization=ToolAuthRequirement(
|
|
provider_type="oauth2",
|
|
provider_id="test-provider",
|
|
)
|
|
)
|
|
|
|
tool_context = ToolContext()
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.auth_tool"
|
|
)
|
|
|
|
# Should return error response
|
|
assert isinstance(result, JSONRPCResponse)
|
|
assert isinstance(result.result, CallToolResult)
|
|
assert result.result.isError is True
|
|
content_text = result.result.content[0].text
|
|
assert "Missing Arcade API key" in content_text
|
|
assert "requires authorization" in content_text
|
|
assert "ARCADE_API_KEY" in content_text
|
|
assert result.result.structuredContent is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_auth_pending(self, mcp_server):
|
|
"""Test tool requirements checking when authorization is pending."""
|
|
|
|
mock_arcade = Mock()
|
|
mcp_server.arcade = mock_arcade
|
|
|
|
# Create a tool that requires authorization
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
authorization=ToolAuthRequirement(
|
|
provider_type="oauth2",
|
|
provider_id="test-provider",
|
|
)
|
|
)
|
|
|
|
mock_auth_response = Mock()
|
|
mock_auth_response.status = "pending"
|
|
mock_auth_response.url = "https://example.com/auth"
|
|
|
|
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
|
|
|
tool_context = ToolContext()
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.auth_tool"
|
|
)
|
|
|
|
# Should return error response with authorization URL in content
|
|
assert isinstance(result, JSONRPCResponse)
|
|
assert isinstance(result.result, CallToolResult)
|
|
assert result.result.isError is True
|
|
assert result.result.structuredContent is None
|
|
content_text = result.result.content[0].text
|
|
assert "Authorization required" in content_text
|
|
assert "needs your permission" in content_text
|
|
# The authorization URL is included in the human-readable message
|
|
assert "https://example.com/auth" in content_text
|
|
# Machine-readable fields (authorization_url, llm_instructions) are in content[1]
|
|
assert len(result.result.content) >= 2
|
|
extra_data = json.loads(result.result.content[1].text)
|
|
assert extra_data["authorization_url"] == "https://example.com/auth"
|
|
assert "llm_instructions" in extra_data
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_auth_completed(self, mcp_server):
|
|
"""Test tool requirements checking when authorization is completed."""
|
|
|
|
mock_arcade = Mock()
|
|
mcp_server.arcade = mock_arcade
|
|
|
|
# Create a tool that requires authorization
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
authorization=ToolAuthRequirement(
|
|
provider_type="oauth2",
|
|
provider_id="test-provider",
|
|
)
|
|
)
|
|
|
|
# Mock authorization response as completed
|
|
mock_auth_response = Mock()
|
|
mock_auth_response.status = "completed"
|
|
mock_auth_response.context = Mock()
|
|
mock_auth_response.context.token = "test-token"
|
|
mock_auth_response.context.user_info = {"user_id": "test-user"}
|
|
|
|
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
|
|
|
tool_context = ToolContext()
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.auth_tool"
|
|
)
|
|
|
|
# Should return None (no error) and set authorization context
|
|
assert result is None
|
|
assert tool_context.authorization is not None
|
|
assert tool_context.authorization.token == "test-token"
|
|
assert tool_context.authorization.user_info == {"user_id": "test-user"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_auth_error(self, mcp_server):
|
|
"""Test tool requirements checking when authorization fails."""
|
|
|
|
mock_arcade = Mock()
|
|
mcp_server.arcade = mock_arcade
|
|
|
|
# Create a tool that requires authorization
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
authorization=ToolAuthRequirement(
|
|
provider_type="oauth2",
|
|
provider_id="test-provider",
|
|
)
|
|
)
|
|
|
|
# Mock authorization to raise an error
|
|
mcp_server._check_authorization = AsyncMock(side_effect=ToolRuntimeError("Auth failed"))
|
|
|
|
tool_context = ToolContext()
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.auth_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.auth_tool"
|
|
)
|
|
|
|
# Should return error response
|
|
assert isinstance(result, JSONRPCResponse)
|
|
assert isinstance(result.result, CallToolResult)
|
|
assert result.result.isError is True
|
|
assert result.result.structuredContent is None
|
|
content_text = result.result.content[0].text
|
|
assert "Authorization error" in content_text
|
|
assert "failed to authorize" in content_text
|
|
assert "Auth failed" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_secrets_missing(self, mcp_server):
|
|
"""Test tool requirements checking when required secrets are missing."""
|
|
|
|
# Create a tool that requires secrets
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
secrets=[
|
|
ToolSecretRequirement(key="API_KEY"),
|
|
ToolSecretRequirement(key="DATABASE_URL"),
|
|
]
|
|
)
|
|
|
|
# Mock tool context to raise ValueError for missing secrets
|
|
tool_context = Mock(spec=ToolContext)
|
|
tool_context.get_secret = Mock(side_effect=ValueError("Secret not found"))
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.secret_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.secret_tool"
|
|
)
|
|
|
|
# Should return error response
|
|
assert isinstance(result, JSONRPCResponse)
|
|
assert isinstance(result.result, CallToolResult)
|
|
assert result.result.isError is True
|
|
assert result.result.structuredContent is None
|
|
content_text = result.result.content[0].text
|
|
assert "Missing secret" in content_text
|
|
assert "API_KEY, DATABASE_URL" in content_text
|
|
assert ".env file" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_secrets_partial_missing(self, mcp_server):
|
|
"""Test tool requirements checking when some required secrets are missing."""
|
|
|
|
# Create a tool that requires secrets
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
secrets=[
|
|
ToolSecretRequirement(key="API_KEY"),
|
|
ToolSecretRequirement(key="DATABASE_URL"),
|
|
]
|
|
)
|
|
|
|
# Mock tool context to return a strict subset of the required secrets
|
|
tool_context = Mock(spec=ToolContext)
|
|
|
|
def mock_get_secret(key):
|
|
if key == "API_KEY":
|
|
return "test-api-key"
|
|
else:
|
|
raise ValueError("Secret not found")
|
|
|
|
tool_context.get_secret = Mock(side_effect=mock_get_secret)
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.secret_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.secret_tool"
|
|
)
|
|
|
|
# Should return error response for missing DATABASE_URL
|
|
assert isinstance(result, JSONRPCResponse)
|
|
assert isinstance(result.result, CallToolResult)
|
|
assert result.result.isError is True
|
|
assert result.result.structuredContent is None
|
|
content_text = result.result.content[0].text
|
|
assert "DATABASE_URL" in content_text
|
|
assert "API_KEY" not in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_secrets_available(self, mcp_server):
|
|
"""Test tool requirements checking when all required secrets are available."""
|
|
|
|
# Create a tool that requires secrets
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
secrets=[
|
|
ToolSecretRequirement(key="API_KEY"),
|
|
ToolSecretRequirement(key="DATABASE_URL"),
|
|
]
|
|
)
|
|
|
|
# Mock tool context to return all secrets
|
|
tool_context = Mock(spec=ToolContext)
|
|
|
|
def mock_get_secret(key):
|
|
return f"test-{key.lower()}-value"
|
|
|
|
tool_context.get_secret = Mock(side_effect=mock_get_secret)
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.secret_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.secret_tool"
|
|
)
|
|
|
|
# Should return None (no error) when all secrets are available
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_combined_auth_and_secrets(self, mcp_server):
|
|
"""Test tool requirements checking with both auth and secrets requirements."""
|
|
|
|
mock_arcade = Mock()
|
|
mcp_server.arcade = mock_arcade
|
|
|
|
# Create a tool that requires both auth and secrets
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
authorization=ToolAuthRequirement(
|
|
provider_type="oauth2",
|
|
provider_id="test-provider",
|
|
),
|
|
secrets=[
|
|
ToolSecretRequirement(key="API_KEY"),
|
|
],
|
|
)
|
|
|
|
# Mock successful authorization
|
|
mock_auth_response = Mock()
|
|
mock_auth_response.status = "completed"
|
|
mock_auth_response.context = Mock()
|
|
mock_auth_response.context.token = "test-token"
|
|
mock_auth_response.context.user_info = {"user_id": "test-user"}
|
|
|
|
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
|
|
|
tool_context = ToolContext()
|
|
tool_context.set_secret("API_KEY", "test-api-key")
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.combined_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.combined_tool"
|
|
)
|
|
|
|
# Should return None (no error) when both requirements are satisfied
|
|
assert result is None
|
|
# Authorization context should be set
|
|
assert tool_context.authorization is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_tool_requirements_combined_auth_fails_first(self, mcp_server):
|
|
"""Test tool requirements checking when auth fails before secrets are checked."""
|
|
|
|
mock_arcade = Mock()
|
|
mcp_server.arcade = mock_arcade
|
|
|
|
# Create a tool that requires both auth and secrets
|
|
tool = Mock()
|
|
tool.definition.requirements = ToolRequirements(
|
|
authorization=ToolAuthRequirement(
|
|
provider_type="oauth2",
|
|
provider_id="test-provider",
|
|
),
|
|
secrets=[
|
|
ToolSecretRequirement(key="API_KEY"),
|
|
],
|
|
)
|
|
|
|
# Mock authorization as pending (should fail before secrets check)
|
|
mock_auth_response = Mock()
|
|
mock_auth_response.status = "pending"
|
|
mock_auth_response.url = "https://example.com/auth"
|
|
|
|
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
|
|
|
# Create real tool context (secrets check shouldn't be reached)
|
|
tool_context = ToolContext()
|
|
tool_context.set_secret("API_KEY", "test-api-key")
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.combined_tool", "arguments": {}},
|
|
)
|
|
|
|
result = await mcp_server._check_tool_requirements(
|
|
tool, tool_context, message, "TestToolkit.combined_tool"
|
|
)
|
|
|
|
# Should return auth error (auth is checked first)
|
|
assert isinstance(result, JSONRPCResponse)
|
|
assert isinstance(result.result, CallToolResult)
|
|
assert result.result.isError is True
|
|
assert result.result.structuredContent is None
|
|
content_text = result.result.content[0].text
|
|
# The authorization URL appears in the human-readable message text
|
|
assert "https://example.com/auth" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_transport_blocks_tool_with_auth(
|
|
self, mcp_server, materialized_tool_with_auth
|
|
):
|
|
"""Test that HTTP transport blocks tools requiring oauth."""
|
|
# Create a mock session with HTTP transport
|
|
session = Mock()
|
|
session.init_options = {"transport_type": "http"}
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={
|
|
"name": "TestToolkit.sample_tool_with_auth",
|
|
"arguments": {"text": "test"},
|
|
},
|
|
)
|
|
response = await mcp_server._handle_call_tool(message, session=session)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.isError is True
|
|
assert response.result.structuredContent is None
|
|
content_text = response.result.content[0].text
|
|
assert "HTTP transport" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_transport_blocks_tool_with_secrets(self, mcp_server):
|
|
"""Test that HTTP transport blocks tools requiring secrets."""
|
|
from arcade_core.schema import ToolSecretRequirement
|
|
|
|
tool_def = ToolDefinition(
|
|
name="secret_tool",
|
|
fully_qualified_name="TestToolkit.secret_tool",
|
|
description="A tool requiring secrets",
|
|
toolkit=ToolkitDefinition(
|
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
|
),
|
|
input=ToolInput(
|
|
parameters=[
|
|
InputParameter(
|
|
name="text",
|
|
required=True,
|
|
description="Input text",
|
|
value_schema=ValueSchema(val_type="string"),
|
|
)
|
|
]
|
|
),
|
|
output=ToolOutput(
|
|
description="Tool output", value_schema=ValueSchema(val_type="string")
|
|
),
|
|
requirements=ToolRequirements(
|
|
secrets=[ToolSecretRequirement(key="API_KEY", description="API Key")]
|
|
),
|
|
)
|
|
|
|
@tool(requires_secrets=["SECRET_KEY"])
|
|
def secret_tool_func(text: Annotated[str, "Input text"]) -> Annotated[str, "Secret text"]:
|
|
"""Secret tool function"""
|
|
return "Secret"
|
|
|
|
input_model, output_model = create_func_models(secret_tool_func)
|
|
meta = ToolMeta(module=secret_tool_func.__module__, toolkit="TestToolkit")
|
|
materialized_tool = MaterializedTool(
|
|
tool=secret_tool_func,
|
|
definition=tool_def,
|
|
meta=meta,
|
|
input_model=input_model,
|
|
output_model=output_model,
|
|
)
|
|
|
|
await mcp_server._tool_manager.add_tool(materialized_tool)
|
|
|
|
# Create a mock session with HTTP transport
|
|
session = Mock()
|
|
session.init_options = {"transport_type": "http"}
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.secret_tool", "arguments": {"text": "test"}},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message, session=session)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.isError is True
|
|
assert response.result.structuredContent is None
|
|
content_text = response.result.content[0].text
|
|
assert "HTTP transport" in content_text
|
|
assert "secrets" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_transport_blocks_tool_with_both_auth_and_secrets(self, mcp_server):
|
|
"""Test that HTTP transport blocks tools requiring both auth and secrets."""
|
|
from arcade_core.schema import ToolSecretRequirement
|
|
|
|
# Create a tool with both auth and secret requirements
|
|
tool_def = ToolDefinition(
|
|
name="combined_tool",
|
|
fully_qualified_name="TestToolkit.combined_tool",
|
|
description="A tool requiring both auth and secrets",
|
|
toolkit=ToolkitDefinition(
|
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
|
),
|
|
input=ToolInput(
|
|
parameters=[
|
|
InputParameter(
|
|
name="text",
|
|
required=True,
|
|
description="Input text",
|
|
value_schema=ValueSchema(val_type="string"),
|
|
)
|
|
]
|
|
),
|
|
output=ToolOutput(
|
|
description="Tool output", value_schema=ValueSchema(val_type="string")
|
|
),
|
|
requirements=ToolRequirements(
|
|
authorization=ToolAuthRequirement(
|
|
provider_type="oauth2",
|
|
provider_id="test-provider",
|
|
id="test-provider",
|
|
oauth2=OAuth2Requirement(scopes=["test.scope"]),
|
|
),
|
|
secrets=[ToolSecretRequirement(key="API_KEY", description="API Key")],
|
|
),
|
|
)
|
|
|
|
@tool(
|
|
requires_auth=OAuth2(id="test-provider", scopes=["test.scope"]),
|
|
requires_secrets=["API_KEY"],
|
|
)
|
|
def combined_tool_func(
|
|
text: Annotated[str, "Input text"],
|
|
) -> Annotated[str, "Combined text"]:
|
|
"""Combined tool function"""
|
|
return f"Combined: {text}"
|
|
|
|
input_model, output_model = create_func_models(combined_tool_func)
|
|
meta = ToolMeta(module=combined_tool_func.__module__, toolkit="TestToolkit")
|
|
materialized_tool = MaterializedTool(
|
|
tool=combined_tool_func,
|
|
definition=tool_def,
|
|
meta=meta,
|
|
input_model=input_model,
|
|
output_model=output_model,
|
|
)
|
|
|
|
await mcp_server._tool_manager.add_tool(materialized_tool)
|
|
|
|
# Create a mock session with HTTP transport
|
|
session = Mock()
|
|
session.init_options = {"transport_type": "http"}
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.combined_tool", "arguments": {"text": "test"}},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message, session=session)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.isError is True
|
|
assert response.result.structuredContent is None
|
|
content_text = response.result.content[0].text
|
|
assert "Unsupported transport" in content_text
|
|
assert "HTTP transport" in content_text
|
|
assert "authorization" in content_text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stdio_transport_allows_tool_with_auth(
|
|
self, mcp_server, materialized_tool_with_auth
|
|
):
|
|
"""Test that stdio transport allows tools requiring authentication."""
|
|
# Mock Arcade client
|
|
mcp_server.arcade = Mock()
|
|
mock_auth_response = Mock()
|
|
mock_auth_response.status = "completed"
|
|
mock_auth_response.context = Mock()
|
|
mock_auth_response.context.token = "test-token"
|
|
mock_auth_response.context.user_info = {}
|
|
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
|
|
|
# Create a mock session with stdio transport
|
|
session = Mock()
|
|
session.init_options = {"transport_type": "stdio"}
|
|
session.session_id = "test-session"
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={
|
|
"name": "TestToolkit.sample_tool_with_auth",
|
|
"arguments": {"text": "test"},
|
|
},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message, session=session)
|
|
|
|
# Should succeed (isn't blocked by transport check)
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert isinstance(response.result, CallToolResult)
|
|
|
|
assert response.result.isError is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_transport_type_allows_tool_with_auth(
|
|
self, mcp_server, materialized_tool_with_auth
|
|
):
|
|
"""Test backwards compatibility: no transport_type specified allows tools."""
|
|
# Mock Arcade client
|
|
mcp_server.arcade = Mock()
|
|
mock_auth_response = Mock()
|
|
mock_auth_response.status = "completed"
|
|
mock_auth_response.context = Mock()
|
|
mock_auth_response.context.token = "test-token"
|
|
mock_auth_response.context.user_info = {}
|
|
mcp_server._check_authorization = AsyncMock(return_value=mock_auth_response)
|
|
|
|
# Create a mock session without transport_type
|
|
session = Mock()
|
|
session.init_options = {} # No transport_type
|
|
session.session_id = "test-session"
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={
|
|
"name": "TestToolkit.sample_tool_with_auth",
|
|
"arguments": {"text": "test"},
|
|
},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message, session=session)
|
|
|
|
# Should succeed (no transport restriction applies)
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.isError is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_transport_allows_tool_without_requirements(self, mcp_server):
|
|
"""Test that HTTP transport allows tools without auth/secret requirements."""
|
|
# Create a mock session with HTTP transport
|
|
session = Mock()
|
|
session.init_options = {"transport_type": "http"}
|
|
session.session_id = "test-session"
|
|
|
|
message = CallToolRequest(
|
|
jsonrpc="2.0",
|
|
id=1,
|
|
method="tools/call",
|
|
params={"name": "TestToolkit.test_tool", "arguments": {"text": "test"}},
|
|
)
|
|
|
|
response = await mcp_server._handle_call_tool(message, session=session)
|
|
|
|
assert isinstance(response, JSONRPCResponse)
|
|
assert isinstance(response.result, CallToolResult)
|
|
assert response.result.isError is False
|
|
|
|
|
|
class TestMissingSecretsWarnings:
|
|
"""Test startup warnings for missing tool secrets."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_warns_missing_secrets_on_startup(self, tool_catalog, mcp_settings, caplog):
|
|
"""Test that missing secrets trigger warnings during server startup."""
|
|
import logging
|
|
|
|
# Create tool definition with secret requirements
|
|
tool_def = ToolDefinition(
|
|
name="fetch_data",
|
|
fully_qualified_name="TestToolkit.fetch_data",
|
|
description="Fetch data from API.",
|
|
toolkit=ToolkitDefinition(
|
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
|
),
|
|
input=ToolInput(
|
|
parameters=[
|
|
InputParameter(
|
|
name="query",
|
|
required=True,
|
|
description="Search query",
|
|
value_schema=ValueSchema(val_type="string"),
|
|
)
|
|
]
|
|
),
|
|
output=ToolOutput(description="Result", value_schema=ValueSchema(val_type="string")),
|
|
requirements=ToolRequirements(
|
|
secrets=[
|
|
ToolSecretRequirement(key="API_KEY", description="API Key"),
|
|
ToolSecretRequirement(key="SECRET_TOKEN", description="Secret Token"),
|
|
]
|
|
),
|
|
)
|
|
|
|
@tool
|
|
def fetch_data(query: Annotated[str, "Search query"]) -> Annotated[str, "Result"]:
|
|
"""Fetch data from API."""
|
|
return f"Data for {query}"
|
|
|
|
# Add tool to catalog
|
|
|
|
input_model, output_model = create_func_models(fetch_data)
|
|
meta = ToolMeta(module=fetch_data.__module__, toolkit="TestToolkit")
|
|
materialized = MaterializedTool(
|
|
tool=fetch_data,
|
|
definition=tool_def,
|
|
meta=meta,
|
|
input_model=input_model,
|
|
output_model=output_model,
|
|
)
|
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
|
|
|
# Clear any existing secrets from environment
|
|
import os
|
|
|
|
old_api_key = os.environ.pop("API_KEY", None)
|
|
old_secret_token = os.environ.pop("SECRET_TOKEN", None)
|
|
|
|
try:
|
|
# Ensure worker routes are disabled (no ARCADE_WORKER_SECRET)
|
|
mcp_settings.arcade.server_secret = None
|
|
|
|
# Create and start server
|
|
with caplog.at_level(logging.WARNING):
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="Test Server",
|
|
version="1.0.0",
|
|
settings=mcp_settings,
|
|
)
|
|
await server.start()
|
|
|
|
# Check for warning message
|
|
warning_messages = [
|
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
|
]
|
|
|
|
# Should have a warning about missing secrets
|
|
assert any("fetch_data" in msg and "API_KEY" in msg for msg in warning_messages), (
|
|
f"Expected warning about missing API_KEY for fetch_data. Got: {warning_messages}"
|
|
)
|
|
assert any(
|
|
"fetch_data" in msg and "SECRET_TOKEN" in msg for msg in warning_messages
|
|
), (
|
|
f"Expected warning about missing SECRET_TOKEN for fetch_data. Got: {warning_messages}"
|
|
)
|
|
|
|
await server.stop()
|
|
finally:
|
|
# Restore environment
|
|
if old_api_key is not None:
|
|
os.environ["API_KEY"] = old_api_key
|
|
if old_secret_token is not None:
|
|
os.environ["SECRET_TOKEN"] = old_secret_token
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_warning_when_secrets_present(self, tool_catalog, mcp_settings, caplog):
|
|
"""Test that no warnings are shown when secrets are available."""
|
|
import logging
|
|
|
|
# Create tool definition with secret requirements
|
|
tool_def = ToolDefinition(
|
|
name="secure_tool",
|
|
fully_qualified_name="TestToolkit.secure_tool",
|
|
description="Secure tool.",
|
|
toolkit=ToolkitDefinition(
|
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
|
),
|
|
input=ToolInput(
|
|
parameters=[
|
|
InputParameter(
|
|
name="data",
|
|
required=True,
|
|
description="Data",
|
|
value_schema=ValueSchema(val_type="string"),
|
|
)
|
|
]
|
|
),
|
|
output=ToolOutput(description="Result", value_schema=ValueSchema(val_type="string")),
|
|
requirements=ToolRequirements(
|
|
secrets=[ToolSecretRequirement(key="PRESENT_KEY", description="Present Key")]
|
|
),
|
|
)
|
|
|
|
@tool
|
|
def secure_tool(data: Annotated[str, "Data"]) -> Annotated[str, "Result"]:
|
|
"""Secure tool."""
|
|
return f"Processed {data}"
|
|
|
|
# Add tool to catalog
|
|
|
|
input_model, output_model = create_func_models(secure_tool)
|
|
meta = ToolMeta(module=secure_tool.__module__, toolkit="TestToolkit")
|
|
materialized = MaterializedTool(
|
|
tool=secure_tool,
|
|
definition=tool_def,
|
|
meta=meta,
|
|
input_model=input_model,
|
|
output_model=output_model,
|
|
)
|
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
|
|
|
# Set the secret in environment
|
|
import os
|
|
|
|
old_value = os.environ.get("PRESENT_KEY")
|
|
os.environ["PRESENT_KEY"] = "test-value"
|
|
|
|
try:
|
|
# Ensure worker routes are disabled
|
|
mcp_settings.arcade.server_secret = None
|
|
|
|
# Create and start server
|
|
with caplog.at_level(logging.WARNING):
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="Test Server",
|
|
version="1.0.0",
|
|
settings=mcp_settings,
|
|
)
|
|
await server.start()
|
|
|
|
# Check that no warning is logged for this tool
|
|
warning_messages = [
|
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
|
]
|
|
assert not any(
|
|
"secure_tool" in msg and "PRESENT_KEY" in msg for msg in warning_messages
|
|
), f"Should not warn about PRESENT_KEY when it's set. Got: {warning_messages}"
|
|
|
|
await server.stop()
|
|
finally:
|
|
# Restore environment
|
|
if old_value is not None:
|
|
os.environ["PRESENT_KEY"] = old_value
|
|
else:
|
|
os.environ.pop("PRESENT_KEY", None)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_warning_when_worker_routes_enabled(self, tool_catalog, mcp_settings, caplog):
|
|
"""Test that warnings are skipped when worker routes are enabled."""
|
|
import logging
|
|
|
|
# Create tool definition with secret requirements
|
|
tool_def = ToolDefinition(
|
|
name="worker_tool",
|
|
fully_qualified_name="TestToolkit.worker_tool",
|
|
description="Worker tool.",
|
|
toolkit=ToolkitDefinition(
|
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
|
),
|
|
input=ToolInput(
|
|
parameters=[
|
|
InputParameter(
|
|
name="param",
|
|
required=True,
|
|
description="Param",
|
|
value_schema=ValueSchema(val_type="string"),
|
|
)
|
|
]
|
|
),
|
|
output=ToolOutput(description="Result", value_schema=ValueSchema(val_type="string")),
|
|
requirements=ToolRequirements(
|
|
secrets=[ToolSecretRequirement(key="WORKER_API_KEY", description="Worker API Key")]
|
|
),
|
|
)
|
|
|
|
@tool
|
|
def worker_tool(param: Annotated[str, "Param"]) -> Annotated[str, "Result"]:
|
|
"""Worker tool."""
|
|
return f"Result: {param}"
|
|
|
|
# Add tool to catalog
|
|
|
|
input_model, output_model = create_func_models(worker_tool)
|
|
meta = ToolMeta(module=worker_tool.__module__, toolkit="TestToolkit")
|
|
materialized = MaterializedTool(
|
|
tool=worker_tool,
|
|
definition=tool_def,
|
|
meta=meta,
|
|
input_model=input_model,
|
|
output_model=output_model,
|
|
)
|
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
|
|
|
# Clear the secret from environment
|
|
import os
|
|
|
|
old_value = os.environ.pop("WORKER_API_KEY", None)
|
|
|
|
try:
|
|
# Enable worker routes by setting ARCADE_WORKER_SECRET
|
|
mcp_settings.arcade.server_secret = "test-worker-secret"
|
|
|
|
# Create and start server
|
|
with caplog.at_level(logging.WARNING):
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="Test Server",
|
|
version="1.0.0",
|
|
settings=mcp_settings,
|
|
)
|
|
await server.start()
|
|
|
|
# Check that no warning is logged (worker routes are enabled)
|
|
warning_messages = [
|
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
|
]
|
|
assert not any(
|
|
"worker_tool" in msg and "WORKER_API_KEY" in msg for msg in warning_messages
|
|
), f"Should not warn when worker routes are enabled. Got: {warning_messages}"
|
|
|
|
await server.stop()
|
|
finally:
|
|
# Restore environment
|
|
if old_value is not None:
|
|
os.environ["WORKER_API_KEY"] = old_value
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_warning_format(self, tool_catalog, mcp_settings, caplog):
|
|
"""Test that warnings use the expected format."""
|
|
import logging
|
|
|
|
# Create tool definition with secret requirement
|
|
tool_def = ToolDefinition(
|
|
name="format_test_tool",
|
|
fully_qualified_name="TestToolkit.format_test_tool",
|
|
description="Format test tool.",
|
|
toolkit=ToolkitDefinition(
|
|
name="TestToolkit", description="Test toolkit", version="1.0.0"
|
|
),
|
|
input=ToolInput(
|
|
parameters=[
|
|
InputParameter(
|
|
name="x",
|
|
required=True,
|
|
description="Input",
|
|
value_schema=ValueSchema(val_type="integer"),
|
|
)
|
|
]
|
|
),
|
|
output=ToolOutput(description="Output", value_schema=ValueSchema(val_type="integer")),
|
|
requirements=ToolRequirements(
|
|
secrets=[
|
|
ToolSecretRequirement(key="FORMAT_TEST_KEY", description="Format Test Key")
|
|
]
|
|
),
|
|
)
|
|
|
|
@tool
|
|
def format_test_tool(x: Annotated[int, "Input"]) -> Annotated[int, "Output"]:
|
|
"""Format test tool."""
|
|
return x * 2
|
|
|
|
# Add tool to catalog
|
|
input_model, output_model = create_func_models(format_test_tool)
|
|
meta = ToolMeta(module=format_test_tool.__module__, toolkit="TestToolkit")
|
|
materialized = MaterializedTool(
|
|
tool=format_test_tool,
|
|
definition=tool_def,
|
|
meta=meta,
|
|
input_model=input_model,
|
|
output_model=output_model,
|
|
)
|
|
tool_catalog._tools[tool_def.get_fully_qualified_name()] = materialized
|
|
|
|
# Clear the secret from environment
|
|
import os
|
|
|
|
old_value = os.environ.pop("FORMAT_TEST_KEY", None)
|
|
|
|
try:
|
|
# Ensure worker routes are disabled
|
|
mcp_settings.arcade.server_secret = None
|
|
|
|
# Create and start server
|
|
with caplog.at_level(logging.WARNING):
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
name="Test Server",
|
|
version="1.0.0",
|
|
settings=mcp_settings,
|
|
)
|
|
await server.start()
|
|
|
|
# Check warning format matches specification
|
|
warning_messages = [
|
|
rec.message for rec in caplog.records if rec.levelno == logging.WARNING
|
|
]
|
|
|
|
# Find the warning for our tool
|
|
matching_warnings = [msg for msg in warning_messages if "format_test_tool" in msg]
|
|
assert len(matching_warnings) > 0, (
|
|
f"Expected warning for format_test_tool. Got: {warning_messages}"
|
|
)
|
|
|
|
warning = matching_warnings[0]
|
|
# Check format: "⚠ Tool 'name' declares secret(s) 'KEY' which are not set"
|
|
assert "Tool 'format_test_tool'" in warning
|
|
assert "not set" in warning
|
|
|
|
await server.stop()
|
|
finally:
|
|
# Restore environment
|
|
if old_value is not None:
|
|
os.environ["FORMAT_TEST_KEY"] = old_value
|
|
|
|
|
|
class TestServerToolMetaExtensions:
|
|
"""Tests for _meta extensions on tools (e.g., MCP Apps ui.resourceUri)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_meta_extensions_applied(self, tool_catalog, mcp_settings):
|
|
"""tool_meta_extensions adds _meta.ui.resourceUri to tools."""
|
|
# Get the FQN of the first tool in the catalog
|
|
first_tool = next(iter(tool_catalog))
|
|
fqn = first_tool.definition.fully_qualified_name
|
|
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
tool_meta_extensions={fqn: {"ui": {"resourceUri": "ui://test/index.html"}}},
|
|
)
|
|
await server.start()
|
|
try:
|
|
tools = await server.tools.list_tools()
|
|
# Find the tool by its sanitized name
|
|
sanitized = fqn.replace(".", "_")
|
|
matched = [t for t in tools if t.name == sanitized]
|
|
assert len(matched) == 1
|
|
assert matched[0].meta is not None
|
|
assert matched[0].meta["ui"]["resourceUri"] == "ui://test/index.html"
|
|
finally:
|
|
await server.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_tool_meta_extensions_by_default(self, tool_catalog, mcp_settings):
|
|
"""Without extensions, tools that have no arcade meta have _meta=None or no ui key."""
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
)
|
|
await server.start()
|
|
try:
|
|
tools = await server.tools.list_tools()
|
|
for t in tools:
|
|
if t.meta:
|
|
assert "ui" not in t.meta
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
class TestServerInitialResources:
|
|
"""Tests for loading build-time resources into MCPServer."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_loads_initial_resources(self, tool_catalog, mcp_settings):
|
|
"""MCPServer with initial_resources makes them available via list/read."""
|
|
from arcade_mcp_server.types import Resource
|
|
|
|
resource = Resource(uri="ui://app/index.html", name="App UI", mimeType="text/html")
|
|
|
|
def handler(uri: str) -> str:
|
|
return "<html>hello</html>"
|
|
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
initial_resources=[(resource, handler)],
|
|
)
|
|
await server.start()
|
|
try:
|
|
resources = await server.resources.list_resources()
|
|
uris = [r.uri for r in resources]
|
|
assert "ui://app/index.html" in uris
|
|
|
|
contents = await server.resources.read_resource("ui://app/index.html")
|
|
assert len(contents) == 1
|
|
assert contents[0].text == "<html>hello</html>" # type: ignore[attr-defined]
|
|
finally:
|
|
await server.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_no_initial_resources_by_default(self, tool_catalog, mcp_settings):
|
|
"""Backward compat: no initial resources by default."""
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
)
|
|
await server.start()
|
|
try:
|
|
resources = await server.resources.list_resources()
|
|
assert resources == []
|
|
finally:
|
|
await server.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_initial_resources_with_async_handler(self, tool_catalog, mcp_settings):
|
|
"""Async handlers work for initial resources."""
|
|
from arcade_mcp_server.types import Resource
|
|
|
|
resource = Resource(uri="ui://app/data.json", name="Data", mimeType="application/json")
|
|
|
|
async def async_handler(uri: str) -> str:
|
|
return '{"key": "value"}'
|
|
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
initial_resources=[(resource, async_handler)],
|
|
)
|
|
await server.start()
|
|
try:
|
|
contents = await server.resources.read_resource("ui://app/data.json")
|
|
assert len(contents) == 1
|
|
assert contents[0].text == '{"key": "value"}' # type: ignore[attr-defined]
|
|
finally:
|
|
await server.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_handle_read_resource_round_trip(self, tool_catalog, mcp_settings):
|
|
"""Integration: _handle_read_resource returns correct JSONRPCResponse."""
|
|
from arcade_mcp_server.types import (
|
|
ReadResourceParams,
|
|
ReadResourceRequest,
|
|
ReadResourceResult,
|
|
Resource,
|
|
TextResourceContents,
|
|
)
|
|
|
|
resource = Resource(uri="ui://app/page.html", name="Page", mimeType="text/html")
|
|
|
|
def handler(uri: str) -> str:
|
|
return "<html><body>Hello</body></html>"
|
|
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
initial_resources=[(resource, handler)],
|
|
)
|
|
await server.start()
|
|
try:
|
|
request = ReadResourceRequest(
|
|
id=1,
|
|
params=ReadResourceParams(uri="ui://app/page.html"),
|
|
)
|
|
response = await server._handle_read_resource(request)
|
|
|
|
assert not hasattr(response, "error"), f"Expected success, got error: {response}"
|
|
result = response.result
|
|
assert isinstance(result, ReadResourceResult)
|
|
assert len(result.contents) == 1
|
|
content = result.contents[0]
|
|
assert isinstance(content, TextResourceContents)
|
|
assert content.text == "<html><body>Hello</body></html>"
|
|
assert content.mimeType == "text/html"
|
|
finally:
|
|
await server.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_loads_initial_template_resources(self, tool_catalog, mcp_settings):
|
|
"""MCPServer with initial ResourceTemplate registers it as a template."""
|
|
from arcade_mcp_server.types import ResourceTemplate
|
|
|
|
tmpl = ResourceTemplate(
|
|
uriTemplate="data://{item_id}", name="Data", mimeType="text/plain"
|
|
)
|
|
|
|
def handler(uri: str, item_id: str) -> str:
|
|
return f"item-{item_id}"
|
|
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
initial_resources=[(tmpl, handler)],
|
|
)
|
|
await server.start()
|
|
try:
|
|
templates = await server.resources.list_resource_templates()
|
|
assert any(t.uriTemplate == "data://{item_id}" for t in templates)
|
|
|
|
contents = await server.resources.read_resource("data://42")
|
|
assert contents[0].text == "item-42"
|
|
finally:
|
|
await server.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_server_loads_template_without_handler(self, tool_catalog, mcp_settings):
|
|
"""MCPServer with ResourceTemplate and no handler registers template only."""
|
|
from arcade_mcp_server.types import ResourceTemplate
|
|
|
|
tmpl = ResourceTemplate(
|
|
uriTemplate="schema://{type}", name="Schema"
|
|
)
|
|
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
initial_resources=[(tmpl, None)],
|
|
)
|
|
await server.start()
|
|
try:
|
|
templates = await server.resources.list_resource_templates()
|
|
assert any(t.uriTemplate == "schema://{type}" for t in templates)
|
|
finally:
|
|
await server.stop()
|
|
|
|
|
|
class TestToolMetaExtensionEdgeCases:
|
|
"""Test apply_meta_extensions edge cases on ToolManager directly."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_fqn_logs_warning(self, tool_catalog, mcp_settings, caplog):
|
|
"""Extensions referencing non-existent tools log a warning and skip."""
|
|
import logging
|
|
|
|
server = MCPServer(
|
|
catalog=tool_catalog,
|
|
settings=mcp_settings,
|
|
tool_meta_extensions={"NonExistent.Tool": {"ui": {"resourceUri": "ui://x"}}},
|
|
)
|
|
with caplog.at_level(logging.WARNING, logger="arcade.mcp.managers.tool"):
|
|
await server.start()
|
|
try:
|
|
assert "skipped: tool not found" in caplog.text
|
|
finally:
|
|
await server.stop()
|