49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
450 lines
16 KiB
Python
450 lines
16 KiB
Python
from typing import Any
|
|
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from mcp_types import (
|
|
LATEST_PROTOCOL_VERSION,
|
|
CallToolResult,
|
|
ClientCapabilities,
|
|
CompleteResult,
|
|
Completion,
|
|
CreateMessageRequestParams,
|
|
CreateMessageResult,
|
|
CreateMessageResultWithTools,
|
|
DiscoverResult,
|
|
EmptyResult,
|
|
GetPromptResult,
|
|
Implementation,
|
|
InitializeRequest,
|
|
InitializeRequestParams,
|
|
InputRequiredResult,
|
|
JSONRPCRequest,
|
|
ListPromptsResult,
|
|
ListResourcesResult,
|
|
ListResourceTemplatesResult,
|
|
ListToolsResult,
|
|
ReadResourceResult,
|
|
Result,
|
|
SamplingCapability,
|
|
SamplingMessage,
|
|
ServerCapabilities,
|
|
TextContent,
|
|
Tool,
|
|
ToolChoice,
|
|
ToolResultContent,
|
|
ToolUseContent,
|
|
client_request_adapter,
|
|
jsonrpc_message_adapter,
|
|
)
|
|
from pydantic import ValidationError
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_jsonrpc_request():
|
|
json_data = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {
|
|
"protocolVersion": LATEST_PROTOCOL_VERSION,
|
|
"capabilities": {"batch": None, "sampling": None},
|
|
"clientInfo": {"name": "mcp", "version": "0.1.0"},
|
|
},
|
|
}
|
|
|
|
request = jsonrpc_message_adapter.validate_python(json_data)
|
|
assert isinstance(request, JSONRPCRequest)
|
|
client_request_adapter.validate_python(request.model_dump(by_alias=True, exclude_none=True))
|
|
|
|
assert request.jsonrpc == "2.0"
|
|
assert request.id == 1
|
|
assert request.method == "initialize"
|
|
assert request.params is not None
|
|
assert request.params["protocolVersion"] == LATEST_PROTOCOL_VERSION
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_method_initialization():
|
|
"""Test that the method is automatically set on object creation.
|
|
Testing just for InitializeRequest to keep the test simple, but should be set for other types as well.
|
|
"""
|
|
initialize_request = InitializeRequest(
|
|
params=InitializeRequestParams(
|
|
protocol_version=LATEST_PROTOCOL_VERSION,
|
|
capabilities=ClientCapabilities(),
|
|
client_info=Implementation(
|
|
name="mcp",
|
|
version="0.1.0",
|
|
),
|
|
)
|
|
)
|
|
|
|
assert initialize_request.method == "initialize", "method should be set to 'initialize'"
|
|
assert initialize_request.params is not None
|
|
assert initialize_request.params.protocol_version == LATEST_PROTOCOL_VERSION
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_use_content():
|
|
"""Test ToolUseContent type for SEP-1577."""
|
|
tool_use_data = {
|
|
"type": "tool_use",
|
|
"name": "get_weather",
|
|
"id": "call_abc123",
|
|
"input": {"location": "San Francisco", "unit": "celsius"},
|
|
}
|
|
|
|
tool_use = ToolUseContent.model_validate(tool_use_data)
|
|
assert tool_use.type == "tool_use"
|
|
assert tool_use.name == "get_weather"
|
|
assert tool_use.id == "call_abc123"
|
|
assert tool_use.input == {"location": "San Francisco", "unit": "celsius"}
|
|
|
|
# Test serialization
|
|
serialized = tool_use.model_dump(by_alias=True, exclude_none=True)
|
|
assert serialized["type"] == "tool_use"
|
|
assert serialized["name"] == "get_weather"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_result_content():
|
|
"""Test ToolResultContent type for SEP-1577."""
|
|
tool_result_data = {
|
|
"type": "tool_result",
|
|
"toolUseId": "call_abc123",
|
|
"content": [{"type": "text", "text": "It's 72°F in San Francisco"}],
|
|
"isError": False,
|
|
}
|
|
|
|
tool_result = ToolResultContent.model_validate(tool_result_data)
|
|
assert tool_result.type == "tool_result"
|
|
assert tool_result.tool_use_id == "call_abc123"
|
|
assert len(tool_result.content) == 1
|
|
assert tool_result.is_error is False
|
|
|
|
# Test with empty content (should default to [])
|
|
minimal_result_data = {"type": "tool_result", "toolUseId": "call_xyz"}
|
|
minimal_result = ToolResultContent.model_validate(minimal_result_data)
|
|
assert minimal_result.content == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_tool_choice():
|
|
"""Test ToolChoice type for SEP-1577."""
|
|
# Test with mode
|
|
tool_choice_data = {"mode": "required"}
|
|
tool_choice = ToolChoice.model_validate(tool_choice_data)
|
|
assert tool_choice.mode == "required"
|
|
|
|
# Test with minimal data (all fields optional)
|
|
minimal_choice = ToolChoice.model_validate({})
|
|
assert minimal_choice.mode is None
|
|
|
|
# Test different modes
|
|
auto_choice = ToolChoice.model_validate({"mode": "auto"})
|
|
assert auto_choice.mode == "auto"
|
|
|
|
none_choice = ToolChoice.model_validate({"mode": "none"})
|
|
assert none_choice.mode == "none"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sampling_message_with_user_role():
|
|
"""Test SamplingMessage with user role for SEP-1577."""
|
|
# Test with single content
|
|
user_msg_data = {"role": "user", "content": {"type": "text", "text": "Hello"}}
|
|
user_msg = SamplingMessage.model_validate(user_msg_data)
|
|
assert user_msg.role == "user"
|
|
assert isinstance(user_msg.content, TextContent)
|
|
|
|
# Test with array of content including tool result
|
|
multi_content_data: dict[str, Any] = {
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "Here's the result:"},
|
|
{"type": "tool_result", "toolUseId": "call_123", "content": []},
|
|
],
|
|
}
|
|
multi_msg = SamplingMessage.model_validate(multi_content_data)
|
|
assert multi_msg.role == "user"
|
|
assert isinstance(multi_msg.content, list)
|
|
assert len(multi_msg.content) == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sampling_message_with_assistant_role():
|
|
"""Test SamplingMessage with assistant role for SEP-1577."""
|
|
# Test with tool use content
|
|
assistant_msg_data = {
|
|
"role": "assistant",
|
|
"content": {
|
|
"type": "tool_use",
|
|
"name": "search",
|
|
"id": "call_456",
|
|
"input": {"query": "MCP protocol"},
|
|
},
|
|
}
|
|
assistant_msg = SamplingMessage.model_validate(assistant_msg_data)
|
|
assert assistant_msg.role == "assistant"
|
|
assert isinstance(assistant_msg.content, ToolUseContent)
|
|
|
|
# Test with array of mixed content
|
|
multi_content_data: dict[str, Any] = {
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Let me search for that..."},
|
|
{"type": "tool_use", "name": "search", "id": "call_789", "input": {}},
|
|
],
|
|
}
|
|
multi_msg = SamplingMessage.model_validate(multi_content_data)
|
|
assert isinstance(multi_msg.content, list)
|
|
assert len(multi_msg.content) == 2
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_sampling_message_backward_compatibility():
|
|
"""Test that SamplingMessage maintains backward compatibility."""
|
|
# Old-style message (single content, no tools)
|
|
old_style_data = {"role": "user", "content": {"type": "text", "text": "Hello"}}
|
|
old_msg = SamplingMessage.model_validate(old_style_data)
|
|
assert old_msg.role == "user"
|
|
assert isinstance(old_msg.content, TextContent)
|
|
|
|
# New-style message with tool content
|
|
new_style_data: dict[str, Any] = {
|
|
"role": "assistant",
|
|
"content": {"type": "tool_use", "name": "test", "id": "call_1", "input": {}},
|
|
}
|
|
new_msg = SamplingMessage.model_validate(new_style_data)
|
|
assert new_msg.role == "assistant"
|
|
assert isinstance(new_msg.content, ToolUseContent)
|
|
|
|
# Array content
|
|
array_style_data: dict[str, Any] = {
|
|
"role": "user",
|
|
"content": [{"type": "text", "text": "Result:"}, {"type": "tool_result", "toolUseId": "call_1", "content": []}],
|
|
}
|
|
array_msg = SamplingMessage.model_validate(array_style_data)
|
|
assert isinstance(array_msg.content, list)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_message_request_params_with_tools():
|
|
"""Test CreateMessageRequestParams with tools for SEP-1577."""
|
|
tool = Tool(
|
|
name="get_weather",
|
|
description="Get weather information",
|
|
input_schema={"type": "object", "properties": {"location": {"type": "string"}}},
|
|
)
|
|
|
|
params = CreateMessageRequestParams(
|
|
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="What's the weather?"))],
|
|
max_tokens=1000,
|
|
tools=[tool],
|
|
tool_choice=ToolChoice(mode="auto"),
|
|
)
|
|
|
|
assert params.tools is not None
|
|
assert len(params.tools) == 1
|
|
assert params.tools[0].name == "get_weather"
|
|
assert params.tool_choice is not None
|
|
assert params.tool_choice.mode == "auto"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_message_result_with_tool_use():
|
|
"""Test CreateMessageResultWithTools with tool use content for SEP-1577."""
|
|
result_data = {
|
|
"role": "assistant",
|
|
"content": {"type": "tool_use", "name": "search", "id": "call_123", "input": {"query": "test"}},
|
|
"model": "claude-3",
|
|
"stopReason": "toolUse",
|
|
}
|
|
|
|
# Tool use content uses CreateMessageResultWithTools
|
|
result = CreateMessageResultWithTools.model_validate(result_data)
|
|
assert result.role == "assistant"
|
|
assert isinstance(result.content, ToolUseContent)
|
|
assert result.stop_reason == "toolUse"
|
|
assert result.model == "claude-3"
|
|
|
|
# Test content_as_list with single content (covers else branch)
|
|
content_list = result.content_as_list
|
|
assert len(content_list) == 1
|
|
assert content_list[0] == result.content
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_create_message_result_basic():
|
|
"""Test CreateMessageResult with basic text content (backwards compatible)."""
|
|
result_data = {
|
|
"role": "assistant",
|
|
"content": {"type": "text", "text": "Hello!"},
|
|
"model": "claude-3",
|
|
"stopReason": "endTurn",
|
|
}
|
|
|
|
# Basic content uses CreateMessageResult (single content, no arrays)
|
|
result = CreateMessageResult.model_validate(result_data)
|
|
assert result.role == "assistant"
|
|
assert isinstance(result.content, TextContent)
|
|
assert result.content.text == "Hello!"
|
|
assert result.stop_reason == "endTurn"
|
|
assert result.model == "claude-3"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_client_capabilities_with_sampling_tools():
|
|
"""Test ClientCapabilities with nested sampling capabilities for SEP-1577."""
|
|
# New structured format
|
|
capabilities_data: dict[str, Any] = {
|
|
"sampling": {"tools": {}},
|
|
}
|
|
capabilities = ClientCapabilities.model_validate(capabilities_data)
|
|
assert capabilities.sampling is not None
|
|
assert isinstance(capabilities.sampling, SamplingCapability)
|
|
assert capabilities.sampling.tools is not None
|
|
|
|
# With both context and tools
|
|
full_capabilities_data: dict[str, Any] = {"sampling": {"context": {}, "tools": {}}}
|
|
full_caps = ClientCapabilities.model_validate(full_capabilities_data)
|
|
assert isinstance(full_caps.sampling, SamplingCapability)
|
|
assert full_caps.sampling.context is not None
|
|
assert full_caps.sampling.tools is not None
|
|
|
|
|
|
def test_tool_preserves_json_schema_2020_12_fields():
|
|
"""Verify that JSON Schema 2020-12 keywords are preserved in Tool.inputSchema.
|
|
|
|
SEP-1613 establishes JSON Schema 2020-12 as the default dialect for MCP.
|
|
This test ensures the SDK doesn't strip $schema, $defs, or additionalProperties.
|
|
"""
|
|
input_schema = {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"$defs": {
|
|
"address": {
|
|
"type": "object",
|
|
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
|
|
}
|
|
},
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"address": {"$ref": "#/$defs/address"},
|
|
},
|
|
"additionalProperties": False,
|
|
}
|
|
|
|
tool = Tool(name="test_tool", description="A test tool", input_schema=input_schema)
|
|
|
|
# Verify fields are preserved in the model
|
|
assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
|
assert "$defs" in tool.input_schema
|
|
assert "address" in tool.input_schema["$defs"]
|
|
assert tool.input_schema["additionalProperties"] is False
|
|
|
|
# Verify fields survive serialization round-trip
|
|
serialized = tool.model_dump(mode="json", by_alias=True)
|
|
assert serialized["inputSchema"]["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
|
assert "$defs" in serialized["inputSchema"]
|
|
assert serialized["inputSchema"]["additionalProperties"] is False
|
|
|
|
|
|
def test_list_tools_result_preserves_json_schema_2020_12_fields():
|
|
"""Verify JSON Schema 2020-12 fields survive ListToolsResult deserialization."""
|
|
raw_response = {
|
|
"tools": [
|
|
{
|
|
"name": "json_schema_tool",
|
|
"description": "Tool with JSON Schema 2020-12 features",
|
|
"inputSchema": {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"$defs": {"item": {"type": "string"}},
|
|
"properties": {"items": {"type": "array", "items": {"$ref": "#/$defs/item"}}},
|
|
"additionalProperties": False,
|
|
},
|
|
}
|
|
]
|
|
}
|
|
|
|
result = ListToolsResult.model_validate(raw_response)
|
|
tool = result.tools[0]
|
|
|
|
assert tool.input_schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
|
|
assert "$defs" in tool.input_schema
|
|
assert tool.input_schema["additionalProperties"] is False
|
|
|
|
|
|
def _wire_dump(result: Result) -> dict[str, Any]:
|
|
return result.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
|
|
|
|
def test_concrete_wire_results_always_dump_result_type_complete():
|
|
"""Required by 2026-07-28; the runner's per-version sieve drops it for older peers."""
|
|
carriers: list[Result] = [
|
|
CompleteResult(completion=Completion(values=[])),
|
|
GetPromptResult(messages=[]),
|
|
CallToolResult(content=[]),
|
|
ReadResourceResult(contents=[]),
|
|
ListPromptsResult(prompts=[]),
|
|
ListResourcesResult(resources=[]),
|
|
ListResourceTemplatesResult(resource_templates=[]),
|
|
ListToolsResult(tools=[]),
|
|
DiscoverResult(
|
|
supported_versions=["2026-07-28"],
|
|
capabilities=ServerCapabilities(),
|
|
server_info=Implementation(name="server", version="1.0"),
|
|
),
|
|
]
|
|
for result in carriers:
|
|
assert _wire_dump(result)["resultType"] == "complete", type(result).__name__
|
|
|
|
|
|
def test_cacheable_results_default_to_immediately_stale_private():
|
|
"""`ttl_ms`/`cache_scope` default to 0/"private" so list-results validate at
|
|
2026-07-28 without the handler setting them, and never accidentally enable
|
|
shared caching."""
|
|
cacheable: list[Result] = [
|
|
ReadResourceResult(contents=[]),
|
|
ListPromptsResult(prompts=[]),
|
|
ListResourceTemplatesResult(resource_templates=[]),
|
|
ListResourcesResult(resources=[]),
|
|
ListToolsResult(tools=[]),
|
|
DiscoverResult(
|
|
supported_versions=["2026-07-28"],
|
|
capabilities=ServerCapabilities(),
|
|
server_info=Implementation(name="server", version="1.0"),
|
|
),
|
|
]
|
|
for result in cacheable:
|
|
dumped = _wire_dump(result)
|
|
assert dumped["ttlMs"] == 0, type(result).__name__
|
|
assert dumped["cacheScope"] == "private", type(result).__name__
|
|
explicit = _wire_dump(ListToolsResult(tools=[], ttl_ms=5, cache_scope="public"))
|
|
assert explicit["ttlMs"] == 5
|
|
assert explicit["cacheScope"] == "public"
|
|
|
|
|
|
def test_empty_result_dumps_no_fields_by_default():
|
|
"""Deployed peers reject extra keys on empty results, so resultType is never volunteered."""
|
|
assert _wire_dump(EmptyResult()) == snapshot({})
|
|
|
|
|
|
def test_empty_result_dumps_result_type_only_when_explicitly_tagged():
|
|
assert _wire_dump(EmptyResult(result_type="complete")) == snapshot({"resultType": "complete"})
|
|
|
|
|
|
def test_input_required_result_dumps_its_discriminating_tag():
|
|
assert _wire_dump(InputRequiredResult(request_state="s")) == snapshot(
|
|
{"resultType": "input_required", "requestState": "s"}
|
|
)
|
|
|
|
|
|
def test_input_required_result_requires_at_least_one_of_input_requests_or_request_state():
|
|
with pytest.raises(ValidationError):
|
|
InputRequiredResult()
|
|
with pytest.raises(ValidationError):
|
|
InputRequiredResult(input_requests={})
|
|
assert InputRequiredResult(request_state="s").input_requests is None
|