Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/mcp/server/mcpserver/prompts/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .base import Prompt
from .base import Prompt, PromptValidationError
from .manager import PromptManager

__all__ = ["Prompt", "PromptManager"]
__all__ = ["Prompt", "PromptManager", "PromptValidationError"]
23 changes: 21 additions & 2 deletions src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@
from mcp.server.mcpserver.context import Context


class PromptValidationError(ValueError):
"""Raised when prompt arguments fail validation (e.g. a required argument is missing).

A `ValueError` subclass so existing callers that catch `ValueError` are
unaffected. It exists to distinguish this expected, user-input validation
failure from the generic `ValueError` `Prompt.render` also raises when the
prompt function itself raises an unexpected exception - callers that log
the two differently (see `MCPServer.get_prompt`) can `except` this
specifically without downgrading a genuine crash to a quiet warning.
"""


class Message(BaseModel):
"""Base class for all prompt messages.

Expand Down Expand Up @@ -166,15 +178,16 @@ 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.
PromptValidationError: If required arguments are missing.
ValueError: If the prompt function itself raises an unexpected exception.
"""
# Validate required arguments
if self.arguments:
required = {arg.name for arg in self.arguments if arg.required}
provided = set(arguments or {})
missing = required - provided
if missing:
raise ValueError(f"Missing required arguments: {missing}")
raise PromptValidationError(f"Missing required arguments: {missing}")

try:
# Add context to arguments if needed
Expand All @@ -183,6 +196,7 @@ async def render(
fn = self.fn
if is_async_callable(fn):
result = await fn(**call_args)

else:
result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args))

Expand All @@ -198,16 +212,21 @@ async def render(
for msg in result: # type: ignore[reportUnknownVariableType]
if isinstance(msg, Message):
messages.append(msg)

elif isinstance(msg, dict):
messages.append(message_validator.validate_python(msg))

elif isinstance(msg, str | ContentBlock | Image | Audio): # bare content is one user message
messages.append(UserMessage(msg))

else: # pragma: no cover
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
messages.append(Message(role="user", content=content))

return messages

except MCPError:
raise

except Exception as exc:
raise ValueError(f"Error rendering prompt {self.name}") from exc
17 changes: 16 additions & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
UnexpectedResourceError,
UnexpectedToolError,
)
from mcp.server.mcpserver.prompts import Prompt, PromptManager
from mcp.server.mcpserver.prompts import Prompt, PromptManager, PromptValidationError
from mcp.server.mcpserver.resources import (
DEFAULT_RESOURCE_SECURITY,
FunctionResource,
Expand Down Expand Up @@ -1323,6 +1323,7 @@ async def get_prompt(
"""
if context is None:
context = Context(mcp_server=self, subscriptions=self._subscriptions)

try:
prompt = self._prompt_manager.get_prompt(name)
if not prompt:
Expand All @@ -1336,8 +1337,22 @@ async def get_prompt(
description=prompt.description,
messages=pydantic_core.to_jsonable_python(rendered),
)

except MCPError:
raise

except PromptValidationError as e:
# Expected user-input validation failures, like missing required
# arguments, don't need a full traceback. Log a concise warning
# instead of the exc_info dump reserved for unexpected errors.

# `Prompt.render` also raises a plain `ValueError` when the prompt
# function itself throws, so this narrower type is caught here
# instead of `ValueError` to avoid swallowing that traceback too.

logger.warning(f"Error getting prompt {name}: {e}")
raise ValueError(str(e)) from e

except Exception as e:
# Not logged here: the dispatcher boundary logs it once.
raise ValueError(str(e)) from e
Expand Down
61 changes: 61 additions & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,67 @@ 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_without_traceback(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""Regression for issue #3342: missing-argument ValueErrors are an expected
validation failure, so `MCPServer.get_prompt`'s own logger should emit
a plain warning without exc_info, instead of a full traceback.

Note: `jsonrpc_dispatcher` has a separate, intentional catch-all that
logs a traceback for any handler exception it doesn't recognize as
`MCPError`/`ValidationError`. However, that generic safety net is out of
scope here and unaffected by this fix PR #3347.
"""
mcp = MCPServer()

@mcp.prompt()
def prompt_fn(name: str) -> str: ... # pragma: no branch.

# In Python 3.14, coverage.py undercounts a branch when `caplog.at_level`
# wraps `async with Client(...): with pytest.raises(...): await ...` as
# a 4th nesting level around a single `await` statement (3 levels of
# nesting is OK; 4 is not). So `caplog.set_level` avoids extra `with` layer.
caplog.set_level(logging.WARNING, logger="mcp.server.mcpserver.server")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This regression test only inspects mcp.server.mcpserver.server, so it can pass even if the same missing-arg failure still emits a traceback from mcp.shared.jsonrpc_dispatcher. Assert the dispatcher logger too, or this test gives false confidence about the reported no-traceback behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/server/mcpserver/test_server.py, line 1560:

<comment>This regression test only inspects `mcp.server.mcpserver.server`, so it can pass even if the same missing-arg failure still emits a traceback from `mcp.shared.jsonrpc_dispatcher`. Assert the dispatcher logger too, or this test gives false confidence about the reported no-traceback behavior.</comment>

<file context>
@@ -1536,6 +1536,67 @@ def prompt_fn(name: str) -> str: ...  # pragma: no branch
+        # wraps `async with Client(...): with pytest.raises(...): await ...` as
+        # a 4th nesting level around a single `await` statement (3 levels of
+        # nesting is OK; 4 is not). So `caplog.set_level` avoids extra `with` layer.
+        caplog.set_level(logging.WARNING, logger="mcp.server.mcpserver.server")
+        async with Client(mcp, mode="legacy") as client:
+            with pytest.raises(MCPError, match="Missing required arguments"):
</file context>
Suggested change
caplog.set_level(logging.WARNING, logger="mcp.server.mcpserver.server")
caplog.set_level(logging.WARNING, logger="mcp.server.mcpserver.server")
caplog.set_level(logging.WARNING, logger="mcp.shared.jsonrpc_dispatcher")

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already investigated — see earlier discussion with @keeltrace on this PR.

I tried exactly this — raising MCPError from the PromptValidationError branch so the dispatcher recognizes it as expected too — in 8c30d12, and it broke tests/interaction/mcpserver/test_prompts.py and tests/docs_src/test_prompts.py::test_missing_required_argument_is_a_protocol_error.

Legacy JSONRPCDispatcher and modern Client/HTTP entry deliberately return different wire shapes — code=0, message=str(e) verbatim vs generic code=-32603, message="Internal server error" — for an unrecognized exception at that boundary, so a single exception raised from get_prompt can't satisfy both.

Reverted at ae720ad. Test's docstring documents this scoping decision — asserting dispatcher logger here would make the test fail against expected. This is intentional behavior and not a bug.

async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError, match="Missing required arguments"):
await client.get_prompt("prompt_fn")

server_records = [r for r in caplog.records if r.name == "mcp.server.mcpserver.server"]
assert len(server_records) == 1

# Missing-argument PromptValidationError, which is a ValueError subclass,
# logs as a plain warning, with no exc_info/traceback at all.
assert server_records[0].levelno == logging.WARNING
assert not server_records[0].exc_info

async def test_get_prompt_unexpected_error_still_logs_traceback(self, caplog: pytest.LogCaptureFixture) -> None:
"""A prompt function raising an unexpected (non-validation) exception must
still be logged with a full traceback, even though `Prompt.render` also
wraps it as a plain `ValueError` — same wire type as the missing-argument
case, but not a `PromptValidationError`, so it must not be downgraded."""
mcp = MCPServer()

@mcp.prompt()
def prompt_fn() -> str:
raise KeyError("boom")

# In Python 3.14, coverage.py undercounts a branch when `caplog.at_level`
# wraps `async with Client(...): with pytest.raises(...): await ...` as
# a 4th nesting level around a single `await` statement (3 levels of
# nesting is OK; 4 is not). So `caplog.set_level` avoids extra `with` layer.
caplog.set_level(logging.WARNING, logger="mcp.shared.jsonrpc_dispatcher")
async with Client(mcp, mode="legacy") as client:
with pytest.raises(MCPError):
await client.get_prompt("prompt_fn")

dispatcher_records = [r for r in caplog.records if r.name == "mcp.shared.jsonrpc_dispatcher"]
assert len(dispatcher_records) == 1

# Unexpected errors should have error log with exc_info.
assert dispatcher_records[0].levelno == logging.ERROR
assert dispatcher_records[0].exc_info is not None


async def test_resource_decorator_rfc6570_reserved_expansion():
# Regression: old regex-based param extraction couldn't see `path`
Expand Down
Loading