Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

343 lines
14 KiB
Python

"""Test URL mode elicitation feature (SEP 1036)."""
import anyio
import mcp_types as types
import pytest
from mcp_types import ElicitRequestParams, ElicitResult, TextContent
from pydantic import BaseModel, Field
from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server.elicitation import CancelledElicitation, DeclinedElicitation, elicit_url
from mcp.server.mcpserver import Context, MCPServer
@pytest.mark.anyio
async def test_url_elicitation_accept():
"""Test URL mode elicitation with user acceptance."""
mcp = MCPServer(name="URLElicitationServer")
@mcp.tool(description="A tool that uses URL elicitation")
async def request_api_key(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Please provide your API key to continue.",
url="https://example.com/api_key_setup",
elicitation_id="test-elicitation-001",
)
# Test only checks accept path
return f"User {result.action}"
# Create elicitation callback that accepts URL mode
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.url == "https://example.com/api_key_setup"
assert params.elicitation_id == "test-elicitation-001"
assert params.message == "Please provide your API key to continue."
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("request_api_key", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "User accept"
@pytest.mark.anyio
async def test_url_elicitation_decline():
"""Test URL mode elicitation with user declining."""
mcp = MCPServer(name="URLElicitationDeclineServer")
@mcp.tool(description="A tool that uses URL elicitation")
async def oauth_flow(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Authorize access to your files.",
url="https://example.com/oauth/authorize",
elicitation_id="oauth-001",
)
# Test only checks decline path
return f"User {result.action} authorization"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="decline")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("oauth_flow", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "User decline authorization"
@pytest.mark.anyio
async def test_url_elicitation_cancel():
"""Test URL mode elicitation with user cancelling."""
mcp = MCPServer(name="URLElicitationCancelServer")
@mcp.tool(description="A tool that uses URL elicitation")
async def payment_flow(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Complete payment to proceed.",
url="https://example.com/payment",
elicitation_id="payment-001",
)
# Test only checks cancel path
return f"User {result.action} payment"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
return ElicitResult(action="cancel")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("payment_flow", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "User cancel payment"
@pytest.mark.anyio
async def test_url_elicitation_helper_function():
"""Test the elicit_url helper function."""
mcp = MCPServer(name="URLElicitationHelperServer")
@mcp.tool(description="Tool using elicit_url helper")
async def setup_credentials(ctx: Context) -> str:
result = await elicit_url(
session=ctx.session,
message="Set up your credentials",
url="https://example.com/setup",
elicitation_id="setup-001",
)
# Test only checks accept path - return the type name
return type(result).__name__
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("setup_credentials", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "AcceptedUrlElicitation"
@pytest.mark.anyio
async def test_url_no_content_in_response():
"""Test that URL mode elicitation responses don't include content field."""
mcp = MCPServer(name="URLContentCheckServer")
@mcp.tool(description="Check URL response format")
async def check_url_response(ctx: Context) -> str:
result = await ctx.session.elicit_url(
message="Test message",
url="https://example.com/test",
elicitation_id="test-001",
)
# URL mode responses should not have content
assert result.content is None
return f"Action: {result.action}, Content: {result.content}"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify that this is URL mode
assert params.mode == "url"
assert isinstance(params, types.ElicitRequestURLParams)
# URL params have url and elicitation_id, not requested_schema
assert params.url == "https://example.com/test"
assert params.elicitation_id == "test-001"
# Return without content - this is correct for URL mode
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("check_url_response", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert "Content: None" in result.content[0].text
@pytest.mark.anyio
async def test_form_mode_still_works():
"""Ensure form mode elicitation still works after SEP 1036."""
mcp = MCPServer(name="FormModeBackwardCompatServer")
class NameSchema(BaseModel):
name: str = Field(description="Your name")
@mcp.tool(description="Test form mode")
async def ask_name(ctx: Context) -> str:
result = await ctx.elicit(message="What is your name?", schema=NameSchema)
# Test only checks accept path with data
assert result.action == "accept"
assert result.data is not None
return f"Hello, {result.data.name}!"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify form mode parameters
assert params.mode == "form"
assert isinstance(params, types.ElicitRequestFormParams)
# Form params have requested_schema, not url/elicitation_id
assert params.requested_schema is not None
return ElicitResult(action="accept", content={"name": "Alice"})
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("ask_name", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Hello, Alice!"
@pytest.mark.anyio
async def test_elicit_complete_notification():
"""Test that elicitation completion notifications can be sent and received."""
mcp = MCPServer(name="ElicitCompleteServer")
# Track if the notification was sent
notification_sent = False
@mcp.tool(description="Tool that sends completion notification")
async def trigger_elicitation(ctx: Context) -> str:
nonlocal notification_sent
# Simulate an async operation (e.g., user completing auth in browser)
elicitation_id = "complete-test-001"
# Send completion notification
await ctx.session.send_elicit_complete(elicitation_id)
notification_sent = True
return "Elicitation completed"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="accept") # pragma: no cover
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("trigger_elicitation", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Elicitation completed"
# Give time for notification to be processed
await anyio.sleep(0.1)
# Verify the notification was sent
assert notification_sent
@pytest.mark.anyio
async def test_url_elicitation_required_error_code():
"""Test that the URL_ELICITATION_REQUIRED error code is correct."""
# Verify the error code matches the specification (SEP 1036)
assert types.URL_ELICITATION_REQUIRED == -32042, (
"URL_ELICITATION_REQUIRED error code must be -32042 per SEP 1036 specification"
)
@pytest.mark.anyio
async def test_elicit_url_typed_results():
"""Test that elicit_url returns properly typed result objects."""
mcp = MCPServer(name="TypedResultsServer")
@mcp.tool(description="Test declined result")
async def test_decline(ctx: Context) -> str:
result = await elicit_url(
session=ctx.session,
message="Test decline",
url="https://example.com/decline",
elicitation_id="decline-001",
)
if isinstance(result, DeclinedElicitation):
return "Declined"
return "Not declined" # pragma: no cover
@mcp.tool(description="Test cancelled result")
async def test_cancel(ctx: Context) -> str:
result = await elicit_url(
session=ctx.session,
message="Test cancel",
url="https://example.com/cancel",
elicitation_id="cancel-001",
)
if isinstance(result, CancelledElicitation):
return "Cancelled"
return "Not cancelled" # pragma: no cover
# Test declined result
async def decline_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="decline")
async with Client(mcp, mode="legacy", elicitation_callback=decline_callback) as client:
result = await client.call_tool("test_decline", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Declined"
# Test cancelled result
async def cancel_callback(context: ClientRequestContext, params: ElicitRequestParams):
return ElicitResult(action="cancel")
async with Client(mcp, mode="legacy", elicitation_callback=cancel_callback) as client:
result = await client.call_tool("test_cancel", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Cancelled"
@pytest.mark.anyio
async def test_deprecated_elicit_method():
"""Test the deprecated elicit() method for backward compatibility."""
mcp = MCPServer(name="DeprecatedElicitServer")
class EmailSchema(BaseModel):
email: str = Field(description="Email address")
@mcp.tool(description="Test deprecated elicit method")
async def use_deprecated_elicit(ctx: Context) -> str:
# Use the deprecated elicit() method which should call elicit_form()
result = await ctx.session.elicit(
message="Enter your email",
requested_schema=EmailSchema.model_json_schema(),
)
if result.action == "accept" and result.content:
return f"Email: {result.content.get('email', 'none')}"
return "No email provided" # pragma: no cover
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
# Verify this is form mode
assert params.mode == "form"
assert params.requested_schema is not None
return ElicitResult(action="accept", content={"email": "test@example.com"})
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("use_deprecated_elicit", {})
assert len(result.content) == 1
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Email: test@example.com"
@pytest.mark.anyio
async def test_ctx_elicit_url_convenience_method():
"""Test the ctx.elicit_url() convenience method (vs ctx.session.elicit_url())."""
mcp = MCPServer(name="CtxElicitUrlServer")
@mcp.tool(description="A tool that uses ctx.elicit_url() directly")
async def direct_elicit_url(ctx: Context) -> str:
# Use ctx.elicit_url() directly instead of ctx.session.elicit_url()
result = await ctx.elicit_url(
message="Test the convenience method",
url="https://example.com/test",
elicitation_id="ctx-test-001",
)
return f"Result: {result.action}"
async def elicitation_callback(context: ClientRequestContext, params: ElicitRequestParams):
assert params.mode == "url"
assert params.elicitation_id == "ctx-test-001"
return ElicitResult(action="accept")
async with Client(mcp, mode="legacy", elicitation_callback=elicitation_callback) as client:
result = await client.call_tool("direct_elicit_url", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Result: accept"