Pass InputRequiredResult through the MCPServer prompt and resource pipelines (#3020)
This commit is contained in:
parent
8f2c97b769
commit
8d0f928e40
7 changed files with 627 additions and 32 deletions
|
|
@ -10,13 +10,17 @@ from mcp_types import (
|
|||
CreateMessageRequestParams,
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
InputRequiredResult,
|
||||
PromptMessage,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.mrtr import tutorial001, tutorial002, tutorial003
|
||||
from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
|
@ -109,3 +113,51 @@ def test_fulfil_refuses_a_request_it_cannot_answer() -> None:
|
|||
request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64))
|
||||
with pytest.raises(NotImplementedError, match="sampling/createMessage"):
|
||||
tutorial002.fulfil(request)
|
||||
|
||||
|
||||
async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> None:
|
||||
"""tutorial004: `prompts/get` participates in the same flow — the `@mcp.prompt()` function
|
||||
returns the `InputRequiredResult` itself."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
result = await client.session.get_prompt("briefing", allow_input_required=True)
|
||||
assert result == snapshot(
|
||||
InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"audience": ElicitRequest(
|
||||
method="elicitation/create",
|
||||
params=ElicitRequestFormParams(
|
||||
mode="form",
|
||||
message="Who is the briefing for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"audience": {"type": "string"}},
|
||||
"required": ["audience"],
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _answer_audience(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="accept", content={"audience": "the board"})
|
||||
|
||||
|
||||
async def test_the_prompt_auto_loop_returns_the_final_messages() -> None:
|
||||
"""tutorial004 + the page's client-side claim: `get_prompt` drives the same loop, so the
|
||||
caller sees only the complete `GetPromptResult`."""
|
||||
async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client:
|
||||
result = await client.get_prompt("briefing")
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
description="Draft a briefing tuned to its audience.",
|
||||
messages=[
|
||||
PromptMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text="Write a briefing for the board."),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import pytest
|
||||
from mcp_types import (
|
||||
InputRequiredResult,
|
||||
ListResourceTemplatesResult,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
|
@ -49,6 +50,7 @@ async def test_resource_template_edge_cases():
|
|||
|
||||
# Verify valid template works
|
||||
result = await mcp.read_resource("resource://users/123/posts/456")
|
||||
assert not isinstance(result, InputRequiredResult)
|
||||
result_list = list(result)
|
||||
assert len(result_list) == 1
|
||||
assert result_list[0].content == "Post 456 by user 123"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import threading
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import EmbeddedResource, TextContent, TextResourceContents
|
||||
from mcp_types import (
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
EmbeddedResource,
|
||||
InputRequiredResult,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
|
||||
|
|
@ -209,3 +216,30 @@ async def test_sync_fn_runs_in_worker_thread():
|
|||
|
||||
assert messages == [UserMessage(content=TextContent(type="text", text="hello"))]
|
||||
assert fn_thread[0] != main_thread
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_render_passes_input_required_result_through_unchanged():
|
||||
"""Prompt.render returns the InputRequiredResult the function returned, bypassing
|
||||
message conversion entirely (SEP-2322 multi-round-trip pass-through)."""
|
||||
sentinel = InputRequiredResult(
|
||||
input_requests={
|
||||
"who": ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Who is this for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def asking_prompt() -> InputRequiredResult:
|
||||
return sentinel
|
||||
|
||||
prompt = Prompt.from_function(asking_prompt)
|
||||
result = await prompt.render(None, Context())
|
||||
assert result is sentinel
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import threading
|
|||
import anyio
|
||||
import anyio.from_thread
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import InputRequiredResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver.resources import FunctionResource
|
||||
|
|
@ -242,3 +244,20 @@ async def test_sync_fn_does_not_block_event_loop():
|
|||
release.set()
|
||||
|
||||
assert result == ["done"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_read_rejects_an_input_required_result_from_a_static_function():
|
||||
"""A static resource function returning an InputRequiredResult is a mistake (it can
|
||||
never read the retry's input_responses), so read() raises instead of JSON-dumping it."""
|
||||
|
||||
def ask() -> InputRequiredResult:
|
||||
return InputRequiredResult(request_state="round-1")
|
||||
|
||||
resource = FunctionResource(uri="resource://ask", name="ask", fn=ask)
|
||||
with pytest.raises(ValueError) as exc:
|
||||
await resource.read()
|
||||
assert str(exc.value) == snapshot(
|
||||
"Error reading resource resource://ask: static resources cannot return "
|
||||
"InputRequiredResult; only resource template functions participate in the multi-round-trip flow"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import threading
|
|||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import Annotations
|
||||
from mcp_types import Annotations, ElicitRequest, ElicitRequestFormParams, InputRequiredResult
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
|
|
@ -403,6 +403,7 @@ class TestResourceTemplateAnnotations:
|
|||
|
||||
# Create a resource from the template
|
||||
resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context())
|
||||
assert not isinstance(resource, InputRequiredResult)
|
||||
|
||||
# The resource should inherit the template's annotations
|
||||
assert resource.annotations is not None
|
||||
|
|
@ -477,3 +478,30 @@ async def test_sync_fn_runs_in_worker_thread():
|
|||
assert isinstance(resource, FunctionResource)
|
||||
assert await resource.read() == "hello world"
|
||||
assert fn_thread[0] != main_thread
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_resource_passes_input_required_result_through_unchanged():
|
||||
"""create_resource returns the InputRequiredResult the template function returned
|
||||
instead of wrapping it in a FunctionResource (SEP-2322 multi-round-trip pass-through)."""
|
||||
sentinel = InputRequiredResult(
|
||||
input_requests={
|
||||
"who": ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Who is this for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
def ask(topic: str) -> InputRequiredResult:
|
||||
return sentinel
|
||||
|
||||
template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}")
|
||||
result = await template.create_resource("ask://databases", {"topic": "databases"}, Context())
|
||||
assert result is sentinel
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import json
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp_types import InputRequiredResult
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
|
|
@ -89,6 +90,7 @@ async def test_list_resources(mcp: MCPServer):
|
|||
@pytest.mark.anyio
|
||||
async def test_read_resource_dir(mcp: MCPServer):
|
||||
res_iter = await mcp.read_resource("dir://test_dir")
|
||||
assert not isinstance(res_iter, InputRequiredResult)
|
||||
res_list = list(res_iter)
|
||||
assert len(res_list) == 1
|
||||
res = res_list[0]
|
||||
|
|
@ -106,6 +108,7 @@ async def test_read_resource_dir(mcp: MCPServer):
|
|||
@pytest.mark.anyio
|
||||
async def test_read_resource_file(mcp: MCPServer):
|
||||
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
||||
assert not isinstance(res_iter, InputRequiredResult)
|
||||
res_list = list(res_iter)
|
||||
assert len(res_list) == 1
|
||||
res = res_list[0]
|
||||
|
|
@ -122,6 +125,7 @@ async def test_delete_file(mcp: MCPServer, test_dir: Path):
|
|||
async def test_delete_file_and_check_resources(mcp: MCPServer, test_dir: Path):
|
||||
await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")})
|
||||
res_iter = await mcp.read_resource("file://test_dir/example.py")
|
||||
assert not isinstance(res_iter, InputRequiredResult)
|
||||
res_list = list(res_iter)
|
||||
assert len(res_list) == 1
|
||||
res = res_list[0]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from inline_snapshot import snapshot
|
|||
from mcp_types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_PARAMS,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
AudioContent,
|
||||
BlobResourceContents,
|
||||
CallToolResult,
|
||||
|
|
@ -26,6 +27,7 @@ from mcp_types import (
|
|||
Icon,
|
||||
ImageContent,
|
||||
InputRequiredResult,
|
||||
InputResponses,
|
||||
ListPromptsResult,
|
||||
ListRootsRequest,
|
||||
Prompt,
|
||||
|
|
@ -1311,6 +1313,7 @@ class TestServerPrompts:
|
|||
return "Hello, world!"
|
||||
|
||||
result = await mcp.get_prompt("fn")
|
||||
assert not isinstance(result, InputRequiredResult)
|
||||
content = result.messages[0].content
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == "Hello, world!"
|
||||
|
|
@ -1328,6 +1331,7 @@ class TestServerPrompts:
|
|||
assert prompts[0].name == "fn"
|
||||
# Don't compare functions directly since validate_call wraps them
|
||||
content = await prompts[0].render(None, Context())
|
||||
assert not isinstance(content, InputRequiredResult)
|
||||
assert isinstance(content[0].content, TextContent)
|
||||
assert content[0].content.text == "Hello, world!"
|
||||
|
||||
|
|
@ -1343,6 +1347,7 @@ class TestServerPrompts:
|
|||
assert len(prompts) == 1
|
||||
assert prompts[0].name == "custom_name"
|
||||
content = await prompts[0].render(None, Context())
|
||||
assert not isinstance(content, InputRequiredResult)
|
||||
assert isinstance(content[0].content, TextContent)
|
||||
assert content[0].content.text == "Hello, world!"
|
||||
|
||||
|
|
@ -1358,6 +1363,7 @@ class TestServerPrompts:
|
|||
assert len(prompts) == 1
|
||||
assert prompts[0].description == "A custom description"
|
||||
content = await prompts[0].render(None, Context())
|
||||
assert not isinstance(content, InputRequiredResult)
|
||||
assert isinstance(content[0].content, TextContent)
|
||||
assert content[0].content.text == "Hello, world!"
|
||||
|
||||
|
|
@ -1921,6 +1927,283 @@ async def test_tool_reads_input_responses_and_request_state_from_context_on_retr
|
|||
assert block.text == "Hello, Alice! (state=r1)"
|
||||
|
||||
|
||||
def _ask_who() -> ElicitRequest:
|
||||
return ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Who is this for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"name": {"type": "string"}},
|
||||
"required": ["name"],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_prompt_returning_input_required_result_reaches_client_unchanged():
|
||||
"""A prompt function may return an InputRequiredResult and the pipeline passes it
|
||||
through to the client (spec-mandated: SEP-2322 allows it on prompts/get)."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.prompt()
|
||||
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
|
||||
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="round-1")
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(mcp, mode="2026-07-28") as client:
|
||||
result = await client.session.get_prompt("briefing", allow_input_required=True)
|
||||
|
||||
assert isinstance(result, InputRequiredResult)
|
||||
assert result.request_state == "round-1"
|
||||
assert result.input_requests is not None
|
||||
assert result.input_requests["who"].method == "elicitation/create"
|
||||
|
||||
|
||||
async def test_prompt_reads_input_responses_and_request_state_from_context_on_retry():
|
||||
"""The prompts/get retry carries input_responses and request_state to the prompt
|
||||
function via the Context, completing the SEP-2322 multi-round-trip flow."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.prompt()
|
||||
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses and "who" in responses:
|
||||
who = responses["who"]
|
||||
assert isinstance(who, ElicitResult) and who.content is not None
|
||||
return [UserMessage(content=f"Brief {who.content['name']} (state={ctx.request_state})")]
|
||||
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="r1")
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(mcp, mode="2026-07-28") as client:
|
||||
r1 = await client.session.get_prompt("briefing", allow_input_required=True)
|
||||
assert isinstance(r1, InputRequiredResult)
|
||||
assert r1.input_requests is not None and "who" in r1.input_requests
|
||||
|
||||
r2 = await client.session.get_prompt(
|
||||
"briefing",
|
||||
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
|
||||
request_state=r1.request_state,
|
||||
allow_input_required=True,
|
||||
)
|
||||
assert isinstance(r2, GetPromptResult)
|
||||
block = r2.messages[0].content
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == "Brief Alice (state=r1)"
|
||||
|
||||
|
||||
async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error():
|
||||
"""Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so
|
||||
the runner rejects the frame with -32603 — the same posture the tools path has."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.prompt()
|
||||
async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult:
|
||||
return InputRequiredResult(input_requests={"who": _ask_who()})
|
||||
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.get_prompt("briefing")
|
||||
assert exc.value.error.code == INTERNAL_ERROR
|
||||
assert exc.value.error.message == "Handler returned an invalid result"
|
||||
|
||||
|
||||
async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error():
|
||||
"""Pins the shared era gate for resources/read: a pre-2026 session has no
|
||||
input_required vocabulary, so the runner rejects the frame with -32603."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("ask://{topic}")
|
||||
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
|
||||
return InputRequiredResult(input_requests={"who": _ask_who()})
|
||||
|
||||
async with Client(mcp, mode="legacy") as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.read_resource("ask://databases")
|
||||
assert exc.value.error.code == INTERNAL_ERROR
|
||||
assert exc.value.error.message == "Handler returned an invalid result"
|
||||
|
||||
|
||||
async def test_resource_template_returning_input_required_result_reaches_client_unchanged():
|
||||
"""A resource template function may return an InputRequiredResult and the pipeline
|
||||
passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read)."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("ask://{topic}")
|
||||
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
|
||||
return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="round-1")
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(mcp, mode="2026-07-28") as client:
|
||||
result = await client.session.read_resource("ask://databases", allow_input_required=True)
|
||||
|
||||
assert isinstance(result, InputRequiredResult)
|
||||
assert result.request_state == "round-1"
|
||||
assert result.input_requests is not None
|
||||
assert result.input_requests["who"].method == "elicitation/create"
|
||||
|
||||
|
||||
async def test_resource_template_reads_input_responses_from_context_on_retry():
|
||||
"""The resources/read retry carries input_responses to the template function via the
|
||||
Context, completing the SEP-2322 multi-round-trip flow."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("ask://{topic}")
|
||||
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
|
||||
responses = ctx.input_responses
|
||||
if responses and "who" in responses:
|
||||
who = responses["who"]
|
||||
assert isinstance(who, ElicitResult) and who.content is not None
|
||||
return f"{topic} notes for {who.content['name']}"
|
||||
return InputRequiredResult(input_requests={"who": _ask_who()})
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(mcp, mode="2026-07-28") as client:
|
||||
r1 = await client.session.read_resource("ask://databases", allow_input_required=True)
|
||||
assert isinstance(r1, InputRequiredResult)
|
||||
assert r1.input_requests is not None and "who" in r1.input_requests
|
||||
|
||||
r2 = await client.session.read_resource(
|
||||
"ask://databases",
|
||||
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
|
||||
allow_input_required=True,
|
||||
)
|
||||
assert isinstance(r2, ReadResourceResult)
|
||||
contents = r2.contents[0]
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.text == "databases notes for Alice"
|
||||
|
||||
|
||||
async def test_context_read_resource_raises_on_input_required_result():
|
||||
"""ctx.read_resource is a content reader: an InputRequiredResult from the template
|
||||
raises with a pointer at the forwarding path instead of widening every caller."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("ask://{topic}")
|
||||
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
|
||||
return InputRequiredResult(input_requests={"who": _ask_who()})
|
||||
|
||||
context = Context(mcp_server=mcp)
|
||||
with pytest.raises(RuntimeError) as exc:
|
||||
await context.read_resource("ask://databases")
|
||||
assert str(exc.value) == snapshot(
|
||||
"Resource returned InputRequiredResult; ctx.read_resource() only returns "
|
||||
"content — use MCPServer.read_resource(uri, context) to receive and forward it."
|
||||
)
|
||||
|
||||
|
||||
async def test_mcpserver_read_resource_returns_input_required_result_for_handler_forwarding():
|
||||
"""MCPServer.read_resource hands the template's InputRequiredResult to a direct caller
|
||||
unchanged — the composition path for a handler that forwards it as its own result."""
|
||||
mcp = MCPServer()
|
||||
sentinel = InputRequiredResult(input_requests={"who": _ask_who()})
|
||||
|
||||
@mcp.resource("ask://{topic}")
|
||||
async def ask(topic: str, ctx: Context) -> str | InputRequiredResult:
|
||||
return sentinel
|
||||
|
||||
context = Context(mcp_server=mcp)
|
||||
result = await mcp.read_resource("ask://databases", context)
|
||||
assert result is sentinel
|
||||
|
||||
|
||||
async def test_context_read_resource_keeps_outer_input_responses_from_the_nested_template():
|
||||
"""ctx.read_resource never participates in the multi-round-trip flow, so the nested
|
||||
template must not see the outer request's input_responses/request_state — a colliding
|
||||
key would otherwise consume an answer meant for the outer handler's own question."""
|
||||
mcp = MCPServer()
|
||||
seen_responses: list[InputResponses | None] = []
|
||||
seen_state: list[str | None] = []
|
||||
|
||||
@mcp.resource("ask://{topic}")
|
||||
async def ask(topic: str, ctx: Context) -> str:
|
||||
seen_responses.append(ctx.input_responses)
|
||||
seen_state.append(ctx.request_state)
|
||||
return f"{topic} content"
|
||||
|
||||
@mcp.tool()
|
||||
async def outer(ctx: Context) -> str:
|
||||
contents = list(await ctx.read_resource("ask://databases"))
|
||||
assert isinstance(contents[0].content, str)
|
||||
return contents[0].content
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(mcp, mode="2026-07-28") as client:
|
||||
result = await client.session.call_tool(
|
||||
"outer",
|
||||
input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})},
|
||||
request_state="outer-state",
|
||||
)
|
||||
assert isinstance(result, CallToolResult)
|
||||
block = result.content[0]
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == "databases content"
|
||||
assert seen_responses == [None]
|
||||
assert seen_state == [None]
|
||||
|
||||
|
||||
async def test_prompt_raising_mcp_error_surfaces_code_and_data_to_client():
|
||||
"""A handler-raised MCPError keeps its code and data through the prompt pipeline —
|
||||
the same parity tools/call has, needed for self-service capability rejection."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.prompt()
|
||||
async def briefing(ctx: Context) -> str:
|
||||
raise MCPError(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message="needs elicitation",
|
||||
data={"requiredCapabilities": ["elicitation"]},
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.get_prompt("briefing")
|
||||
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc.value.error.message == "needs elicitation"
|
||||
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
|
||||
|
||||
|
||||
async def test_resource_template_raising_mcp_error_surfaces_code_and_data_to_client():
|
||||
"""A handler-raised MCPError keeps its code and data through the resource template
|
||||
pipeline instead of being wrapped into a generic ResourceError."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("ask://{topic}")
|
||||
async def ask(topic: str, ctx: Context) -> str:
|
||||
raise MCPError(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message="needs elicitation",
|
||||
data={"requiredCapabilities": ["elicitation"]},
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.read_resource("ask://databases")
|
||||
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc.value.error.message == "needs elicitation"
|
||||
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
|
||||
|
||||
|
||||
async def test_static_resource_raising_mcp_error_surfaces_code_and_data_to_client():
|
||||
"""A handler-raised MCPError keeps its code and data through the static resource
|
||||
read path too — parity with the template path above."""
|
||||
mcp = MCPServer()
|
||||
|
||||
@mcp.resource("static://thing")
|
||||
def thing() -> str:
|
||||
raise MCPError(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message="needs elicitation",
|
||||
data={"requiredCapabilities": ["elicitation"]},
|
||||
)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.read_resource("static://thing")
|
||||
assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc.value.error.message == "needs elicitation"
|
||||
assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]}
|
||||
|
||||
|
||||
async def test_context_exposes_client_capabilities_from_connection():
|
||||
mcp = MCPServer()
|
||||
seen: list[ClientCapabilities | None] = []
|
||||
|
|
|
|||
Loading…
Reference in a new issue