60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
347 lines
12 KiB
Python
347 lines
12 KiB
Python
import json
|
|
from typing import cast
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from mcp_types import TextContent
|
|
from pydantic_core import to_json
|
|
|
|
from fastmcp import Client, Context, FastMCP
|
|
from fastmcp.client.sampling import RequestContext, SamplingMessage, SamplingParams
|
|
from fastmcp.server.sampling import SamplingResult, SamplingTool
|
|
from fastmcp.utilities.types import Image
|
|
|
|
|
|
@pytest.fixture
|
|
def fastmcp_server():
|
|
mcp = FastMCP()
|
|
|
|
@mcp.tool
|
|
async def simple_sample(message: str, context: Context) -> str:
|
|
result = await context.sample("Hello, world!")
|
|
assert isinstance(result, SamplingResult)
|
|
assert result.text is not None
|
|
return result.text
|
|
|
|
@mcp.tool
|
|
async def sample_with_system_prompt(message: str, context: Context) -> str:
|
|
result = await context.sample("Hello, world!", system_prompt="You love FastMCP")
|
|
assert isinstance(result, SamplingResult)
|
|
assert result.text is not None
|
|
return result.text
|
|
|
|
@mcp.tool
|
|
async def sample_with_messages(message: str, context: Context) -> str:
|
|
result = await context.sample(
|
|
[
|
|
"Hello!",
|
|
SamplingMessage(
|
|
content=TextContent(
|
|
type="text", text="How can I assist you today?"
|
|
),
|
|
role="assistant",
|
|
),
|
|
]
|
|
)
|
|
assert isinstance(result, SamplingResult)
|
|
assert result.text is not None
|
|
return result.text
|
|
|
|
@mcp.tool
|
|
async def sample_with_image(image_bytes: bytes, context: Context) -> str:
|
|
image = Image(data=image_bytes)
|
|
|
|
result = await context.sample(
|
|
[
|
|
SamplingMessage(
|
|
content=TextContent(type="text", text="What's in this image?"),
|
|
role="user",
|
|
),
|
|
SamplingMessage(
|
|
content=image.to_image_content(),
|
|
role="user",
|
|
),
|
|
]
|
|
)
|
|
assert isinstance(result, SamplingResult)
|
|
assert result.text is not None
|
|
return result.text
|
|
|
|
return mcp
|
|
|
|
|
|
async def test_simple_sampling(fastmcp_server: FastMCP):
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
|
) -> str:
|
|
return "This is the sample message!"
|
|
|
|
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
|
result = await client.call_tool("simple_sample", {"message": "Hello, world!"})
|
|
assert result.data == "This is the sample message!"
|
|
|
|
|
|
async def test_sampling_with_system_prompt(fastmcp_server: FastMCP):
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
|
) -> str:
|
|
assert params.system_prompt is not None
|
|
return params.system_prompt
|
|
|
|
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
|
result = await client.call_tool(
|
|
"sample_with_system_prompt", {"message": "Hello, world!"}
|
|
)
|
|
assert result.data == "You love FastMCP"
|
|
|
|
|
|
async def test_sampling_with_messages(fastmcp_server: FastMCP):
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
|
) -> str:
|
|
assert len(messages) == 2
|
|
|
|
assert isinstance(messages[0].content, TextContent)
|
|
assert messages[0].content.type == "text"
|
|
assert messages[0].content.text == "Hello!"
|
|
|
|
assert isinstance(messages[1].content, TextContent)
|
|
assert messages[1].content.type == "text"
|
|
assert messages[1].content.text == "How can I assist you today?"
|
|
return "I need to think."
|
|
|
|
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
|
result = await client.call_tool(
|
|
"sample_with_messages", {"message": "Hello, world!"}
|
|
)
|
|
assert result.data == "I need to think."
|
|
|
|
|
|
async def test_sampling_with_fallback(fastmcp_server: FastMCP):
|
|
openai_sampling_handler = AsyncMock(return_value="But I need to think")
|
|
|
|
fastmcp_server = FastMCP(
|
|
sampling_handler=openai_sampling_handler,
|
|
)
|
|
|
|
@fastmcp_server.tool
|
|
async def sample_with_fallback(context: Context) -> str:
|
|
sampling_result = await context.sample("Do not think.")
|
|
return cast(TextContent, sampling_result).text
|
|
|
|
client = Client(fastmcp_server)
|
|
|
|
async with client:
|
|
call_tool_result = await client.call_tool("sample_with_fallback")
|
|
|
|
assert call_tool_result.data == "But I need to think"
|
|
|
|
|
|
async def test_sampling_with_image(fastmcp_server: FastMCP):
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
|
) -> str:
|
|
assert len(messages) == 2
|
|
return to_json(messages).decode()
|
|
|
|
async with Client(fastmcp_server, sampling_handler=sampling_handler) as client:
|
|
image_bytes = b"abc123"
|
|
result = await client.call_tool(
|
|
"sample_with_image", {"image_bytes": image_bytes}
|
|
)
|
|
assert json.loads(result.data) == [
|
|
{
|
|
"role": "user",
|
|
"content": {
|
|
"type": "text",
|
|
"text": "What's in this image?",
|
|
"annotations": None,
|
|
"_meta": None,
|
|
},
|
|
"_meta": None,
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": {
|
|
"type": "image",
|
|
"data": "YWJjMTIz",
|
|
"mimeType": "image/png",
|
|
"annotations": None,
|
|
"_meta": None,
|
|
},
|
|
"_meta": None,
|
|
},
|
|
]
|
|
|
|
|
|
class TestSamplingDefaultCapabilities:
|
|
"""Tests for default sampling capability advertisement (issue #3329)."""
|
|
|
|
async def test_default_sampling_capabilities_omit_tools(self):
|
|
"""Default sampling capabilities should not include tools field.
|
|
|
|
When serialized with exclude_none=True (as the MCP session does),
|
|
the capability should produce {"sampling": {}} rather than
|
|
{"sampling": {"tools": {}}}, ensuring compatibility with servers
|
|
that don't recognize the tools sub-field (e.g. older Java MCP SDK).
|
|
"""
|
|
import mcp_types
|
|
|
|
server = FastMCP()
|
|
|
|
def handler(
|
|
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
|
) -> str:
|
|
return "ok"
|
|
|
|
client = Client(server, sampling_handler=handler)
|
|
caps = client._session_kwargs["sampling_capabilities"]
|
|
assert isinstance(caps, mcp_types.SamplingCapability)
|
|
assert caps.tools is None
|
|
|
|
async def test_set_sampling_callback_default_capabilities_omit_tools(self):
|
|
"""set_sampling_callback should also default to no tools capability."""
|
|
import mcp_types
|
|
|
|
server = FastMCP()
|
|
client = Client(server)
|
|
client.set_sampling_callback(lambda msgs, params, ctx: "ok")
|
|
caps = client._session_kwargs["sampling_capabilities"]
|
|
assert isinstance(caps, mcp_types.SamplingCapability)
|
|
assert caps.tools is None
|
|
|
|
async def test_explicit_tools_capability_is_preserved(self):
|
|
"""Explicitly passing tools capability should be respected."""
|
|
import mcp_types
|
|
|
|
server = FastMCP()
|
|
|
|
def handler(
|
|
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
|
) -> str:
|
|
return "ok"
|
|
|
|
explicit_caps = mcp_types.SamplingCapability(
|
|
tools=mcp_types.SamplingToolsCapability()
|
|
)
|
|
client = Client(
|
|
server, sampling_handler=handler, sampling_capabilities=explicit_caps
|
|
)
|
|
caps = client._session_kwargs["sampling_capabilities"]
|
|
assert isinstance(caps, mcp_types.SamplingCapability)
|
|
assert caps.tools is not None
|
|
|
|
|
|
class TestSamplingWithTools:
|
|
"""Tests for sampling with tools functionality."""
|
|
|
|
async def test_sampling_with_tools_requires_capability(self):
|
|
"""Test that sampling with tools raises error when client lacks capability."""
|
|
import mcp_types
|
|
|
|
from fastmcp.exceptions import ToolError
|
|
|
|
server = FastMCP()
|
|
|
|
def search(query: str) -> str:
|
|
"""Search the web."""
|
|
return f"Results for: {query}"
|
|
|
|
@server.tool
|
|
async def sample_with_tool(context: Context) -> str:
|
|
# This should fail because the client doesn't advertise tools capability
|
|
result = await context.sample(
|
|
messages="Search for Python tutorials",
|
|
tools=[search],
|
|
)
|
|
return str(result)
|
|
|
|
def sampling_handler(
|
|
messages: list[SamplingMessage], params: SamplingParams, ctx: RequestContext
|
|
) -> str:
|
|
return "Response"
|
|
|
|
# Explicitly disable tools capability by passing SamplingCapability without tools
|
|
async with Client(
|
|
server,
|
|
sampling_handler=sampling_handler,
|
|
sampling_capabilities=mcp_types.SamplingCapability(), # No tools
|
|
) as client:
|
|
with pytest.raises(ToolError, match="sampling.tools capability"):
|
|
await client.call_tool("sample_with_tool", {})
|
|
|
|
async def test_sampling_with_tools_fallback_handler_can_return_string(self):
|
|
"""Test that fallback handler can return a string even when tools are provided.
|
|
|
|
The LLM might choose not to use any tools and just return a text response.
|
|
"""
|
|
# This handler returns a string - valid even when tools are provided
|
|
simple_handler = AsyncMock(return_value="Direct response without tools")
|
|
|
|
mcp = FastMCP(sampling_handler=simple_handler)
|
|
|
|
def search(query: str) -> str:
|
|
"""Search the web."""
|
|
return f"Results for: {query}"
|
|
|
|
@mcp.tool
|
|
async def sample_with_tool(context: Context) -> str:
|
|
result = await context.sample(
|
|
messages="Search for Python tutorials",
|
|
tools=[search],
|
|
)
|
|
return result.text or "no text"
|
|
|
|
# Client without sampling handler - will use server's fallback
|
|
async with Client(mcp) as client:
|
|
result = await client.call_tool("sample_with_tool", {})
|
|
|
|
# Handler returned string directly, which is treated as final text response
|
|
assert result.data == "Direct response without tools"
|
|
|
|
def test_sampling_tool_schema(self):
|
|
"""Test that SamplingTool generates correct schema."""
|
|
|
|
def search(query: str, limit: int = 10) -> str:
|
|
"""Search the web for results."""
|
|
return f"Results for: {query}"
|
|
|
|
tool = SamplingTool.from_function(search)
|
|
assert tool.name == "search"
|
|
assert tool.description == "Search the web for results."
|
|
assert "query" in tool.parameters.get("properties", {})
|
|
assert "limit" in tool.parameters.get("properties", {})
|
|
|
|
async def test_sampling_tool_run(self):
|
|
"""Test that SamplingTool.run() executes correctly."""
|
|
|
|
def add(a: int, b: int) -> int:
|
|
"""Add two numbers."""
|
|
return a + b
|
|
|
|
tool = SamplingTool.from_function(add)
|
|
result = await tool.run({"a": 5, "b": 3})
|
|
assert result == 8
|
|
|
|
async def test_sampling_tool_run_async(self):
|
|
"""Test that SamplingTool.run() works with async functions."""
|
|
|
|
async def async_multiply(a: int, b: int) -> int:
|
|
"""Multiply two numbers."""
|
|
return a * b
|
|
|
|
tool = SamplingTool.from_function(async_multiply)
|
|
result = await tool.run({"a": 4, "b": 7})
|
|
assert result == 28
|
|
|
|
def test_tool_choice_parameter(self):
|
|
"""Test that tool_choice parameter accepts string literals."""
|
|
from fastmcp.server.context import ToolChoiceOption
|
|
|
|
# Verify ToolChoiceOption type accepts the valid string values
|
|
choices: list[ToolChoiceOption] = ["auto", "required", "none"]
|
|
assert len(choices) == 3
|
|
assert "auto" in choices
|
|
assert "required" in choices
|
|
assert "none" in choices
|