arcade-mcp/libs/tests/arcade_mcp_server/test_server.py
Eric Gustin 05682d54fe
Don't return structuredContent when error (#817)
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 -->
2026-04-10 15:27:07 -07:00

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