Skip to content

Commit 16c6aef

Browse files
BabyChrist666claude
andcommitted
refactor: consistent raise-based error handling in MCPServer handlers
Implements the pattern described in #2153: MCPServer users should raise exceptions and the framework converts them to appropriate protocol responses, without leaking internal details. Changes: - ToolError raised by user → CallToolResult(is_error=True) with user's message - ResourceError raised by user → MCPError (JSON-RPC error) with user's message - PromptError (new) raised by user → MCPError (JSON-RPC error) with user's message - MCPError → re-raised as-is for protocol-level errors - Unexpected exceptions → sanitized message (no internal detail leakage) Files modified: - exceptions.py: Add PromptError class - tools/base.py: Let ToolError/MCPError pass through Tool.run() without wrapping - resources/types.py: Let ResourceError/MCPError pass through FunctionResource.read() - prompts/base.py: Let PromptError/MCPError pass through Prompt.render() - prompts/manager.py: Raise PromptError instead of ValueError for unknown prompts - server.py: Add handler-level error handling to _handle_read_resource() and _handle_get_prompt(), explicit ToolError catch in _handle_call_tool() - __init__.py: Export ToolError, ResourceError, PromptError from package Closes #2153 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7ba41dc commit 16c6aef

File tree

12 files changed

+227
-25
lines changed

12 files changed

+227
-25
lines changed

src/mcp/server/mcpserver/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
from mcp.types import Icon
44

55
from .context import Context
6+
from .exceptions import PromptError, ResourceError, ToolError
67
from .server import MCPServer
78
from .utilities.types import Audio, Image
89

9-
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
10+
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon", "ToolError", "ResourceError", "PromptError"]

src/mcp/server/mcpserver/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,9 @@ class ToolError(MCPServerError):
1717
"""Error in tool operations."""
1818

1919

20+
class PromptError(MCPServerError):
21+
"""Error in prompt operations."""
22+
23+
2024
class InvalidSignature(Exception):
2125
"""Invalid signature for use with MCPServer."""

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
import pydantic_core
1010
from pydantic import BaseModel, Field, TypeAdapter, validate_call
1111

12+
from mcp.server.mcpserver.exceptions import PromptError
1213
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
1314
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
15+
from mcp.shared.exceptions import MCPError
1416
from mcp.types import ContentBlock, Icon, TextContent
1517

1618
if TYPE_CHECKING:
@@ -141,15 +143,15 @@ async def render(
141143
"""Render the prompt with arguments.
142144
143145
Raises:
144-
ValueError: If required arguments are missing, or if rendering fails.
146+
PromptError: If required arguments are missing, or if rendering fails.
145147
"""
146148
# Validate required arguments
147149
if self.arguments:
148150
required = {arg.name for arg in self.arguments if arg.required}
149151
provided = set(arguments or {})
150152
missing = required - provided
151153
if missing:
152-
raise ValueError(f"Missing required arguments: {missing}")
154+
raise PromptError(f"Missing required arguments: {missing}")
153155

154156
try:
155157
# Add context to arguments if needed
@@ -182,5 +184,7 @@ async def render(
182184
raise ValueError(f"Could not convert prompt result to message: {msg}")
183185

184186
return messages
187+
except (PromptError, MCPError): # pragma: no cover
188+
raise
185189
except Exception as e: # pragma: no cover
186190
raise ValueError(f"Error rendering prompt {self.name}: {e}")

src/mcp/server/mcpserver/prompts/manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from typing import TYPE_CHECKING, Any
66

7+
from mcp.server.mcpserver.exceptions import PromptError
78
from mcp.server.mcpserver.prompts.base import Message, Prompt
89
from mcp.server.mcpserver.utilities.logging import get_logger
910

@@ -54,6 +55,6 @@ async def render_prompt(
5455
"""Render a prompt by name with arguments."""
5556
prompt = self.get_prompt(name)
5657
if not prompt:
57-
raise ValueError(f"Unknown prompt: {name}")
58+
raise PromptError(f"Unknown prompt: {name}")
5859

5960
return await prompt.render(arguments, context)

src/mcp/server/mcpserver/resources/types.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
import pydantic_core
1414
from pydantic import Field, ValidationInfo, validate_call
1515

16+
from mcp.server.mcpserver.exceptions import ResourceError
1617
from mcp.server.mcpserver.resources.base import Resource
18+
from mcp.shared.exceptions import MCPError
1719
from mcp.types import Annotations, Icon
1820

1921

@@ -69,6 +71,8 @@ async def read(self) -> str | bytes:
6971
return result
7072
else:
7173
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
74+
except (ResourceError, MCPError):
75+
raise
7276
except Exception as e:
7377
raise ValueError(f"Error reading resource {self.uri}: {e}")
7478

src/mcp/server/mcpserver/server.py

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from mcp.server.lowlevel.server import LifespanResultT, Server
3232
from mcp.server.lowlevel.server import lifespan as default_lifespan
3333
from mcp.server.mcpserver.context import Context
34-
from mcp.server.mcpserver.exceptions import ResourceError
34+
from mcp.server.mcpserver.exceptions import PromptError, ResourceError, ToolError
3535
from mcp.server.mcpserver.prompts import Prompt, PromptManager
3636
from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager
3737
from mcp.server.mcpserver.tools import Tool, ToolManager
@@ -44,6 +44,8 @@
4444
from mcp.server.transport_security import TransportSecuritySettings
4545
from mcp.shared.exceptions import MCPError
4646
from mcp.types import (
47+
INTERNAL_ERROR,
48+
INVALID_PARAMS,
4749
Annotations,
4850
BlobResourceContents,
4951
CallToolRequestParams,
@@ -303,8 +305,14 @@ async def _handle_call_tool(
303305
result = await self.call_tool(params.name, params.arguments or {}, context)
304306
except MCPError:
305307
raise
306-
except Exception as e:
308+
except ToolError as e:
307309
return CallToolResult(content=[TextContent(type="text", text=str(e))], is_error=True)
310+
except Exception:
311+
logger.exception(f"Unhandled error in tool {params.name}")
312+
return CallToolResult(
313+
content=[TextContent(type="text", text=f"Internal error executing tool {params.name}")],
314+
is_error=True,
315+
)
308316
if isinstance(result, CallToolResult):
309317
return result
310318
if isinstance(result, tuple) and len(result) == 2:
@@ -332,7 +340,16 @@ async def _handle_read_resource(
332340
self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams
333341
) -> ReadResourceResult:
334342
context = Context(request_context=ctx, mcp_server=self)
335-
results = await self.read_resource(params.uri, context)
343+
try:
344+
results = await self.read_resource(params.uri, context)
345+
except MCPError:
346+
raise
347+
except ResourceError as e:
348+
raise MCPError(code=INVALID_PARAMS, message=str(e))
349+
except Exception:
350+
logger.exception(f"Unhandled error reading resource {params.uri}")
351+
raise MCPError(code=INTERNAL_ERROR, message=f"Internal error reading resource {params.uri}")
352+
336353
contents: list[TextResourceContents | BlobResourceContents] = []
337354
for item in results:
338355
if isinstance(item.content, bytes):
@@ -369,7 +386,15 @@ async def _handle_get_prompt(
369386
self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams
370387
) -> GetPromptResult:
371388
context = Context(request_context=ctx, mcp_server=self)
372-
return await self.get_prompt(params.name, params.arguments, context)
389+
try:
390+
return await self.get_prompt(params.name, params.arguments, context)
391+
except MCPError:
392+
raise
393+
except PromptError as e:
394+
raise MCPError(code=INVALID_PARAMS, message=str(e))
395+
except Exception:
396+
logger.exception(f"Unhandled error in prompt {params.name}")
397+
raise MCPError(code=INTERNAL_ERROR, message=f"Internal error getting prompt {params.name}")
373398

374399
async def list_tools(self) -> list[MCPTool]:
375400
"""List all available tools."""
@@ -444,9 +469,10 @@ async def read_resource(
444469
try:
445470
content = await resource.read()
446471
return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)]
472+
except (ResourceError, MCPError):
473+
raise
447474
except Exception as exc:
448-
logger.exception(f"Error getting resource {uri}")
449-
# If an exception happens when reading the resource, we should not leak the exception to the client.
475+
logger.exception(f"Error reading resource {uri}")
450476
raise ResourceError(f"Error reading resource {uri}") from exc
451477

452478
def add_tool(
@@ -1090,14 +1116,16 @@ async def get_prompt(
10901116
try:
10911117
prompt = self._prompt_manager.get_prompt(name)
10921118
if not prompt:
1093-
raise ValueError(f"Unknown prompt: {name}")
1119+
raise PromptError(f"Unknown prompt: {name}")
10941120

10951121
messages = await prompt.render(arguments, context)
10961122

10971123
return GetPromptResult(
10981124
description=prompt.description,
10991125
messages=pydantic_core.to_jsonable_python(messages),
11001126
)
1127+
except (PromptError, MCPError):
1128+
raise
11011129
except Exception as e:
11021130
logger.exception(f"Error getting prompt {name}")
1103-
raise ValueError(str(e))
1131+
raise PromptError(f"Error getting prompt {name}") from e

src/mcp/server/mcpserver/tools/base.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp.server.mcpserver.exceptions import ToolError
1212
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
1313
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
14-
from mcp.shared.exceptions import UrlElicitationRequiredError
14+
from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError
1515
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
1616
from mcp.types import Icon, ToolAnnotations
1717

@@ -112,12 +112,14 @@ async def run(
112112
result = self.fn_metadata.convert_result(result)
113113

114114
return result
115-
except UrlElicitationRequiredError:
116-
# Re-raise UrlElicitationRequiredError so it can be properly handled
117-
# as an MCP error response with code -32042
115+
except (UrlElicitationRequiredError, MCPError, ToolError):
116+
# Re-raise framework and user-raised exceptions without wrapping.
117+
# - UrlElicitationRequiredError → MCP error response (code -32042)
118+
# - MCPError → JSON-RPC error response
119+
# - ToolError → CallToolResult(is_error=True)
118120
raise
119121
except Exception as e:
120-
raise ToolError(f"Error executing tool {self.name}: {e}") from e
122+
raise ToolError(f"Error executing tool {self.name}") from e
121123

122124

123125
def _is_async_callable(obj: Any) -> bool:

tests/server/mcpserver/prompts/test_base.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44

55
from mcp.server.mcpserver import Context
6+
from mcp.server.mcpserver.exceptions import PromptError
67
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage
78
from mcp.types import EmbeddedResource, TextContent, TextResourceContents
89

@@ -44,7 +45,7 @@ async def fn(name: str, age: int = 30) -> str: # pragma: no cover
4445
return f"Hello, {name}! You're {age} years old."
4546

4647
prompt = Prompt.from_function(fn)
47-
with pytest.raises(ValueError):
48+
with pytest.raises(PromptError):
4849
await prompt.render({"age": 40}, Context())
4950

5051
@pytest.mark.anyio

tests/server/mcpserver/prompts/test_manager.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22

33
from mcp.server.mcpserver import Context
4+
from mcp.server.mcpserver.exceptions import PromptError
45
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
56
from mcp.server.mcpserver.prompts.manager import PromptManager
67
from mcp.types import TextContent
@@ -93,7 +94,7 @@ def fn(name: str) -> str:
9394
async def test_render_unknown_prompt(self):
9495
"""Test rendering a non-existent prompt."""
9596
manager = PromptManager()
96-
with pytest.raises(ValueError, match="Unknown prompt: unknown"):
97+
with pytest.raises(PromptError, match="Unknown prompt: unknown"):
9798
await manager.render_prompt("unknown", None, Context())
9899

99100
@pytest.mark.anyio
@@ -106,5 +107,5 @@ def fn(name: str) -> str: # pragma: no cover
106107
manager = PromptManager()
107108
prompt = Prompt.from_function(fn)
108109
manager.add_prompt(prompt)
109-
with pytest.raises(ValueError, match="Missing required arguments"):
110+
with pytest.raises(PromptError, match="Missing required arguments"):
110111
await manager.render_prompt("fn", None, Context())

0 commit comments

Comments
 (0)