diff --git a/src/mcp/server/mcpserver/exceptions.py b/src/mcp/server/mcpserver/exceptions.py index 239785e9a9..b33675bf83 100644 --- a/src/mcp/server/mcpserver/exceptions.py +++ b/src/mcp/server/mcpserver/exceptions.py @@ -24,3 +24,13 @@ class ToolError(MCPServerError): class InvalidSignature(Exception): """Invalid signature for use with MCPServer.""" + + +class PromptArgumentError(ValueError): + """Raised when required prompt arguments are missing or invalid. + + Inherits from `ValueError` for backward compatibility (existing code that + catches `ValueError` continues to work), but lets the server log these + known validation errors at warning level instead of exception level with + a full traceback — see #3342. + """ diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index d30c0b3c60..e453a96bf2 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -11,6 +11,7 @@ from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent from pydantic import BaseModel, Field, TypeAdapter, validate_call +from mcp.server.mcpserver.exceptions import PromptArgumentError from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.server.mcpserver.utilities.types import Audio, Image @@ -174,7 +175,11 @@ async def render( provided = set(arguments or {}) missing = required - provided if missing: - raise ValueError(f"Missing required arguments: {missing}") + # PromptArgumentError is a ValueError subclass for backward + # compatibility — it just lets the server log at warning level + # instead of exception-with-traceback for known validation + # failures (see issue #3342). + raise PromptArgumentError(f"Missing required arguments: {missing}") try: # Add context to arguments if needed diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index b3a3cb3bcd..e49d56e156 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -71,7 +71,11 @@ from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan from mcp.server.mcpserver.context import Context -from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError +from mcp.server.mcpserver.exceptions import ( + PromptArgumentError, + ResourceError, + ResourceNotFoundError, +) from mcp.server.mcpserver.prompts import Prompt, PromptManager from mcp.server.mcpserver.resources import ( DEFAULT_RESOURCE_SECURITY, @@ -1298,6 +1302,12 @@ async def get_prompt( ) except MCPError: raise + except PromptArgumentError as e: + # Known validation error — log at warning without traceback so + # user-facing "missing required arguments" calls don't pollute + # server logs with full stack traces. See issue #3342. + logger.warning(f"Prompt {name!r} rejected: {e}") + raise ValueError(str(e)) from e except Exception as e: logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) from e diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index f9055a4ed4..bbc91c0baa 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -50,7 +50,7 @@ from mcp.client import Client from mcp.server.context import ServerRequestContext from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity -from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError +from mcp.server.mcpserver.exceptions import PromptArgumentError, ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource from mcp.server.mcpserver.utilities.types import Audio, Image @@ -1527,6 +1527,57 @@ def prompt_fn(name: str) -> str: ... # pragma: no branch with pytest.raises(MCPError, match="Missing required arguments"): await client.get_prompt("prompt_fn") + async def test_get_prompt_missing_args_logs_warning_no_traceback(self, caplog: pytest.LogCaptureFixture): + """Issue #3342: missing required arguments should log at WARNING level, + not as an exception with a full traceback. The error must still + propagate to the client. + """ + import logging + + from mcp.server.mcpserver.utilities.logging import get_logger + + mcp = MCPServer() + + @mcp.prompt() + def prompt_fn(name: str) -> str: ... # pragma: no branch + + logger = get_logger("mcp.server.mcpserver.server") + with caplog.at_level(logging.DEBUG, logger=logger.name): + async with Client(mcp, mode="legacy") as client: + with pytest.raises(MCPError, match="Missing required arguments"): + await client.get_prompt("prompt_fn") + + # Should contain a WARNING entry about the rejected prompt. + warning_records = [r for r in caplog.records if r.name == logger.name and r.levelno == logging.WARNING] + assert any("prompt_fn" in r.getMessage() for r in warning_records), ( + f"Expected WARNING log about prompt_fn rejection; got {[r.getMessage() for r in caplog.records]}" + ) + + # Should NOT contain an ERROR/EXCEPTION entry for this rejection — + # those would imply a full traceback. + error_records = [r for r in caplog.records if r.name == logger.name and r.levelno >= logging.ERROR] + assert not any("prompt_fn" in r.getMessage() for r in error_records), ( + "Did not expect ERROR/EXCEPTION log for known validation error; " + f"got {[r.getMessage() for r in error_records]}" + ) + + async def test_prompt_argument_error_is_value_error_subclass(self): + """Backward compatibility: PromptArgumentError must remain catchable + as ValueError so existing user code keeps working. + """ + mcp = MCPServer() + + @mcp.prompt() + def prompt_fn(name: str) -> str: ... # pragma: no branch + + prompt_obj = mcp._prompt_manager.get_prompt("prompt_fn") + assert prompt_obj is not None + with pytest.raises(ValueError, match="Missing required arguments"): + await prompt_obj.render(None, context=None) # type: ignore[arg-type] + # And also catchable as the specific subclass. + with pytest.raises(PromptArgumentError, match="Missing required arguments"): + await prompt_obj.render(None, context=None) # type: ignore[arg-type] + async def test_resource_decorator_rfc6570_reserved_expansion(): # Regression: old regex-based param extraction couldn't see `path`