diff --git a/docs/servers/prompts.md b/docs/servers/prompts.md index 1ded9b03d0..d9ed090735 100644 --- a/docs/servers/prompts.md +++ b/docs/servers/prompts.md @@ -56,14 +56,14 @@ That is the entire life of a prompt: listed by name, rendered on demand, dropped !!! check `required` is enforced before your function runs. Render `review_code` without `code` and the - request itself fails with a JSON-RPC error (code `-32603`): + request itself fails with a JSON-RPC error (code `-32602`, Invalid params): ```text - mcp.shared.exceptions.MCPError: Internal server error + mcp.shared.exceptions.MCPError: Missing required arguments: {'code'} ``` There is no tool-style error result to hand back to a model, because no model is in the loop: - the call raises. The reason (`Missing required arguments: {'code'}`) lands in your server's log. + the call raises. You can distinguish missing required arguments from internal errors by the error code. ### Try it diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index a13b72f1aa..32372a527a 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -8,7 +8,7 @@ import anyio.to_thread import pydantic_core -from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent +from mcp_types import INVALID_PARAMS, ContentBlock, Icon, InputRequiredResult, TextContent from pydantic import BaseModel, Field, TypeAdapter, validate_call from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context @@ -166,7 +166,9 @@ async def render( through unchanged so the multi-round-trip flow reaches the client. Raises: - ValueError: If required arguments are missing, or if rendering fails. + MCPError: If a required argument is missing, with code `INVALID_PARAMS`, + or if the prompt function raises it. + ValueError: If rendering fails. """ # Validate required arguments if self.arguments: @@ -174,7 +176,7 @@ async def render( provided = set(arguments or {}) missing = required - provided if missing: - raise ValueError(f"Missing required arguments: {missing}") + raise MCPError(code=INVALID_PARAMS, message=f"Missing required arguments: {missing}") try: # Add context to arguments if needed diff --git a/src/mcp/server/mcpserver/prompts/manager.py b/src/mcp/server/mcpserver/prompts/manager.py index 01a0823bf7..6ad5dfb8c6 100644 --- a/src/mcp/server/mcpserver/prompts/manager.py +++ b/src/mcp/server/mcpserver/prompts/manager.py @@ -4,10 +4,11 @@ from typing import TYPE_CHECKING, Any -from mcp_types import InputRequiredResult +from mcp_types import INVALID_PARAMS, InputRequiredResult from mcp.server.mcpserver.prompts.base import Message, Prompt from mcp.server.mcpserver.utilities.logging import get_logger +from mcp.shared.exceptions import MCPError if TYPE_CHECKING: from mcp.server.context import LifespanContextT, RequestT @@ -59,9 +60,15 @@ async def render_prompt( arguments: dict[str, Any] | None, context: Context[LifespanContextT, RequestT], ) -> list[Message] | InputRequiredResult: - """Render a prompt by name with arguments.""" + """Render a prompt by name with arguments. + + Raises: + MCPError: If the prompt name is unknown or a required argument is missing, + with code `INVALID_PARAMS`, or if the prompt function raises it. + ValueError: If rendering fails. + """ prompt = self.get_prompt(name) if not prompt: - raise ValueError(f"Unknown prompt: {name}") + raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") return await prompt.render(arguments, context) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 99b5a56c0a..7efa29108d 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -1348,13 +1348,18 @@ async def get_prompt( through unchanged (the 2026-07-28 multi-round-trip flow); the retry's answers arrive on `ctx.input_responses`, with `ctx.request_state` carrying the echoed opaque state. + + Raises: + MCPError: If the prompt name is unknown or a required argument is missing, + with code `INVALID_PARAMS`, or if the prompt function raises it. + ValueError: If rendering fails. """ if context is None: context = Context(mcp_server=self, subscriptions=self._subscriptions) try: prompt = self._prompt_manager.get_prompt(name) if not prompt: - raise ValueError(f"Unknown prompt: {name}") + raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") rendered = await prompt.render(arguments, context) if isinstance(rendered, InputRequiredResult): diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py index c375ab6149..9b3c6b1d70 100644 --- a/tests/docs_src/test_prompts.py +++ b/tests/docs_src/test_prompts.py @@ -4,7 +4,7 @@ import pytest from inline_snapshot import snapshot -from mcp_types import PromptArgument, PromptMessage, TextContent +from mcp_types import INVALID_PARAMS, PromptArgument, PromptMessage, TextContent from docs_src.prompts import tutorial001, tutorial002, tutorial003 from mcp import Client, MCPError @@ -54,11 +54,11 @@ async def test_missing_required_argument_is_a_protocol_error() -> None: async with Client(tutorial001.mcp) as client: with pytest.raises(MCPError) as exc_info: await client.get_prompt("review_code") - assert exc_info.value.code == -32603 - assert exc_info.value.message == "Internal server error" + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Missing required arguments: {'code'}") # The line a traceback prints, exactly as the page quotes it: the code is not in the message. assert traceback.format_exception_only(exc_info.value) == snapshot( - ["mcp.shared.exceptions.MCPError: Internal server error\n"] + ["mcp.shared.exceptions.MCPError: Missing required arguments: {'code'}\n"] ) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 48e956e662..1d9033b307 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1372,13 +1372,6 @@ def __post_init__(self) -> None: "prompts:get:missing-required-args": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#error-handling", behavior="prompts/get omitting a required argument returns JSON-RPC error -32602 (Invalid params).", - divergence=Divergence( - note=( - "MCPServer's prompt renderer raises a plain ValueError before the prompt function runs, " - "which the low-level server converts to error code 0 with the exception text as the message." - ), - ), - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), "prompts:get:multi-message": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt", @@ -1447,13 +1440,6 @@ def __post_init__(self) -> None: "mcpserver:prompt:unknown-name": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#error-handling", behavior="prompts/get for a name that was never registered returns JSON-RPC error -32602 (Invalid params).", - divergence=Divergence( - note=( - "The spec's example uses -32602 Invalid params for unknown prompts; MCPServer raises " - "ValueError, which the low-level server converts to error code 0." - ), - ), - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), # ═══════════════════════════════════════════════════════════════════════════ # Completion diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index adca86d481..30e719afe9 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -3,6 +3,7 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( + INVALID_PARAMS, ErrorData, GetPromptResult, ListPromptsResult, @@ -78,8 +79,7 @@ def greet(name: str) -> str: async def test_get_unknown_prompt_is_error(connect: Connect) -> None: """Getting a prompt name that was never registered fails with a JSON-RPC error. - The spec reserves -32602 for this case; the SDK reports code 0 (see the divergence note on - the requirement). + The spec recommends `INVALID_PARAMS` for unknown prompt names. """ mcp = MCPServer("prompter") @@ -92,16 +92,15 @@ def greet(name: str) -> str: with pytest.raises(MCPError) as exc_info: await client.get_prompt("nope") - assert exc_info.value.error == snapshot(ErrorData(code=0, message="Unknown prompt: nope")) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.error == snapshot(ErrorData(code=-32602, message="Unknown prompt: nope")) @requirement("prompts:get:missing-required-args") async def test_get_prompt_with_a_missing_required_argument_is_an_error(connect: Connect) -> None: """Getting a prompt without one of its required arguments fails with a JSON-RPC error. - The missing argument is detected before the prompt function is called, but the spec's -32602 - Invalid params is reported as error code 0 with the bare exception text (see the divergence - note on the requirement). + The spec recommends `INVALID_PARAMS`; validation rejects the call before the function runs. """ mcp = MCPServer("prompter") @@ -114,7 +113,8 @@ def greet(name: str) -> str: with pytest.raises(MCPError) as exc_info: await client.get_prompt("greet") - assert exc_info.value.error == snapshot(ErrorData(code=0, message="Missing required arguments: {'name'}")) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.error == snapshot(ErrorData(code=-32602, message="Missing required arguments: {'name'}")) @requirement("mcpserver:prompt:args-validation") diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index 9cae30526e..1136cc19cb 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -4,6 +4,7 @@ import pytest from mcp_types import ( + INVALID_PARAMS, AudioContent, ElicitRequest, ElicitRequestFormParams, @@ -14,6 +15,7 @@ TextResourceContents, ) +from mcp import MCPError from mcp.server.mcpserver import AssistantMessage, Audio, Context, Image, MCPServer, Message, UserMessage from mcp.server.mcpserver.prompts.base import Prompt @@ -55,8 +57,9 @@ async def fn(name: str, age: int = 30) -> str: # pragma: no cover return f"Hello, {name}! You're {age} years old." prompt = Prompt.from_function(fn) - with pytest.raises(ValueError): + with pytest.raises(MCPError) as exc_info: await prompt.render({"age": 40}, Context()) + assert exc_info.value.code == INVALID_PARAMS @pytest.mark.anyio async def test_fn_returns_message(self): diff --git a/tests/server/mcpserver/prompts/test_manager.py b/tests/server/mcpserver/prompts/test_manager.py index 2389d198be..365145336e 100644 --- a/tests/server/mcpserver/prompts/test_manager.py +++ b/tests/server/mcpserver/prompts/test_manager.py @@ -1,6 +1,8 @@ import pytest -from mcp_types import TextContent +from inline_snapshot import snapshot +from mcp_types import INVALID_PARAMS, TextContent +from mcp import MCPError from mcp.server.mcpserver import Context from mcp.server.mcpserver.prompts.base import Prompt, UserMessage from mcp.server.mcpserver.prompts.manager import PromptManager @@ -93,8 +95,10 @@ def fn(name: str) -> str: async def test_render_unknown_prompt(self): """Test rendering a non-existent prompt.""" manager = PromptManager() - with pytest.raises(ValueError, match="Unknown prompt: unknown"): + with pytest.raises(MCPError) as exc_info: await manager.render_prompt("unknown", None, Context()) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Unknown prompt: unknown") @pytest.mark.anyio async def test_render_prompt_with_missing_args(self): @@ -106,5 +110,7 @@ def fn(name: str) -> str: # pragma: no cover manager = PromptManager() prompt = Prompt.from_function(fn) manager.add_prompt(prompt) - with pytest.raises(ValueError, match="Missing required arguments"): + with pytest.raises(MCPError) as exc_info: await manager.render_prompt("fn", None, Context()) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Missing required arguments: {'name'}") diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index ba87b52038..a30e4defba 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1565,8 +1565,10 @@ async def test_get_unknown_prompt(self): mcp = MCPServer() async with Client(mcp, mode="legacy") as client: - with pytest.raises(MCPError, match="Unknown prompt"): + with pytest.raises(MCPError) as exc_info: await client.get_prompt("unknown") + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Unknown prompt: unknown") async def test_get_prompt_missing_args(self): """Test error when required arguments are missing.""" @@ -1576,8 +1578,10 @@ async def test_get_prompt_missing_args(self): def prompt_fn(name: str) -> str: ... # pragma: no branch async with Client(mcp, mode="legacy") as client: - with pytest.raises(MCPError, match="Missing required arguments"): + with pytest.raises(MCPError) as exc_info: await client.get_prompt("prompt_fn") + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == snapshot("Missing required arguments: {'name'}") async def test_resource_decorator_rfc6570_reserved_expansion():