Skip to content
Open
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
2 changes: 2 additions & 0 deletions docs/handlers/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ The default is `"INFO"`.

`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins.

You also don't need a `try`/`except` in every handler just to record failures. When a tool or resource function raises, the SDK logs it for you. **[Handling errors](../servers/handling-errors.md#what-the-server-logs)** explains what gets logged and at which level.

## Try it

Run the server with the MCP Inspector:
Expand Down
4 changes: 2 additions & 2 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1016,9 +1016,9 @@ except MCPError as e:

### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164)

Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response.
Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a resource handler (static or template) that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response.

The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`).
The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`). Likewise, `FunctionResource.read()` and `FileResource.read()` no longer wrap failures in `ValueError`: called directly they raise whatever the function or file read raised, and through `MCPServer.read_resource()` that arrives as `UnexpectedResourceError` (a `ResourceError`) with the original as `__cause__`.

### `Resource` classes reject unknown keyword arguments

Expand Down
25 changes: 21 additions & 4 deletions docs/servers/handling-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,27 @@ Send `get_author` a `title` that isn't a string and the SDK rejects it against t
It means a whole class of `raise` statements you don't write: don't re-validate your own type hints.

!!! info
Everything on this page is what a **client** sees, and the in-memory `Client` you'll write
tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error
back into a traceback: by the time that flag could act, your exception is already the
`is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern.
Everything so far is what a **client** sees, and the in-memory `Client` you'll write tests
with sees exactly the same thing. Even `raise_exceptions=True` doesn't hand a failing tool's
exception back to the caller: by the time that flag could act, your exception is already the
`is_error=True` result. Assert on the result. If you need the traceback, it is in the server's
log (next section), and pytest's `caplog` captures it. **[Testing](../get-started/testing.md)** covers the pattern.

## What the server logs

The server also logs tool and resource failures, and how it logs them depends on whether you anticipated the failure.

`get_author` raised a plain `ValueError`. The model got the message, but the SDK can't tell that you raised it on purpose, so it treats the call as a crash and logs it at `ERROR` with the full traceback. That is what you want on the day the exception is a `KeyError` from deep inside a library and the result text says only `'id'`.

When the failure is one you planned for, say so with `ToolError`:

```python title="server.py" hl_lines="2 12-13"
--8<-- "docs_src/handling_errors/tutorial004.py"
```

`ToolError` comes from `mcp.server.mcpserver.exceptions`. The model reads exactly what it read before. The difference is in your log, where a `ToolError` is a single `INFO` line with no traceback, so a production log at `WARNING` stays quiet until something is actually broken. Bad arguments and unknown tool names are logged at `INFO` too, because those are the caller's mistakes rather than yours.

Resources work the same way. A crashing resource handler is logged at `ERROR` with its traceback, which matters more here because the `-32603` the client receives names only the URI. `ResourceNotFoundError` and `ResourceError` are the anticipated kind and are logged at `INFO`.

## Recap

Expand Down
10 changes: 5 additions & 5 deletions docs/servers/uri-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ The built-in checks stop the common cases but can't know your sandbox
boundary. For filesystem access, use `safe_join` to resolve the path
and verify it stays inside your base directory:

```python title="server.py" hl_lines="4 14"
```python title="server.py" hl_lines="5 15"
--8<-- "docs_src/uri_templates/tutorial002.py"
```

Expand Down Expand Up @@ -199,10 +199,10 @@ These checks are a heuristic pre-filter; for filesystem access,
`safe_join` remains the containment boundary.

!!! tip
If your handler can't fulfil the request (the file doesn't exist,
the id is unknown), raise an exception. The SDK turns it into an
error response. See **[Handling errors](handling-errors.md)** for the difference between a
protocol error and a tool error.
If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise
`ResourceNotFoundError` as `read_manual` does above. The client gets `-32602` with your message
and the URI. An unexpected exception becomes a generic `-32603` instead. See
**[Handling errors](handling-errors.md#a-resource-that-doesnt-exist)**.

## Resources on the low-level Server

Expand Down
2 changes: 2 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ result.structured_content # None

The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise.

If `<message>` alone doesn't tell you what broke and the tool crashed (rather than raising `ToolError`, being unknown, or rejecting an argument), the traceback is in the **server's log** at `ERROR`, as `Tool '<name>' raised an unexpected exception`.

## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool`

You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter.
Expand Down
14 changes: 14 additions & 0 deletions docs_src/handling_errors/tutorial004.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ToolError

mcp = MCPServer("Bookshop")

CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"}


@mcp.tool()
def get_author(title: str) -> str:
"""Look up the author of a book in the catalog."""
if title not in CATALOG:
raise ToolError(f"No book titled {title!r} in the catalog.")
return CATALOG[title]
6 changes: 5 additions & 1 deletion docs_src/uri_templates/tutorial002.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from pathlib import Path

from mcp.server import MCPServer
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
from mcp.shared.path_security import safe_join

mcp = MCPServer("Bookshop")
Expand All @@ -11,4 +12,7 @@
@mcp.resource("manuals://{+path}")
def read_manual(path: str) -> str:
"""A staff manual page, served from a directory on disk."""
return safe_join(DOCS_ROOT, path).read_text(encoding="utf-8")
file = safe_join(DOCS_ROOT, path)
if not file.is_file():
raise ResourceNotFoundError(f"No manual at {path!r}.")
return file.read_text(encoding="utf-8")
8 changes: 6 additions & 2 deletions src/mcp/server/mcpserver/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,12 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
The resource content as either text or bytes

Raises:
ResourceNotFoundError: If no resource or template matches the URI.
ResourceError: If template creation or resource reading fails.
ResourceNotFoundError: If no resource or template matches the URI, or the
handler raised it.
ResourceError: If the resource or template function raises `ResourceError`.
UnexpectedResourceError: If the resource or template function raises anything
else. `__cause__` is the original exception. Left uncaught in a tool, this
is logged as the tool's crash, while the two above are not.
RuntimeError: If the resource returned an `InputRequiredResult`.
"""
assert self._mcp_server is not None, "Context is not available outside of a request"
Expand Down
53 changes: 49 additions & 4 deletions src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,65 @@ class MCPServerError(Exception):


class ResourceError(MCPServerError):
"""Error in resource operations."""
"""A resource failure you anticipated.

Raise this from a resource or resource template handler for a failure you saw
coming: the client receives a `-32603` protocol error carrying your message
(`ResourceNotFoundError` below is the `-32602` variant), and the server logs it
at INFO without a traceback. Any other exception is treated as a crash: the
client gets a generic message naming only the URI, and the server logs the
traceback at ERROR.

The SDK raises it too, and `UnexpectedResourceError` subclasses it, so
`except ResourceError` around `MCPServer.read_resource()` catches every read
failure, crash or not.
"""


class ResourceNotFoundError(ResourceError):
"""Resource does not exist.

Raise this from a resource template handler to signal that the requested instance does not exist;
clients receive `-32602` (invalid params) per
Raise this from a resource handler to signal that the requested instance does not exist.
Clients receive `-32602` (invalid params) per
[SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164).
"""


class UnexpectedResourceError(ResourceError):
"""A resource read failed with something other than `ResourceError` or `MCPError`.

The SDK raises this itself, around a crash in a resource or resource template
handler. You never raise it. `__cause__` is the original exception, which the
server logs with its traceback. The message names only the URI, so the
original text is withheld from the client.
"""


class ToolError(MCPServerError):
"""Error in tool operations."""
"""A tool failure you anticipated.

Raise this from a tool (or a resolver) for a failure you saw coming: the
call returns `is_error=True` with the message in `content`, and the server
logs it at INFO without a traceback. Any other exception reaches the model
the same way but is treated as a crash and logged at ERROR with its traceback.
A `ResourceError` that escapes the tool (say from `ctx.read_resource()`) counts
as anticipated too.

The SDK raises it too, for an unknown tool name and for arguments that fail
the input schema, and `UnexpectedToolError` subclasses it, so `except ToolError`
around `MCPServer.call_tool()` catches every tool failure, crash or not.
"""


class UnexpectedToolError(ToolError):
"""A tool call failed with something other than `ToolError` or `MCPError`.

The SDK raises this itself, around a crash in the tool (or a resolver) or a
return value that fails output conversion. You never raise it. `__cause__` is
the original exception, which the server logs with its traceback before
returning the usual `is_error=True` result. Catch it around
`MCPServer.call_tool()` to tell a crash from a deliberate `ToolError`.
"""


class InvalidSignature(Exception):
Expand Down
4 changes: 2 additions & 2 deletions src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,5 +209,5 @@
return messages
except MCPError:
raise
except Exception as e:
raise ValueError(f"Error rendering prompt {self.name}: {e}")
except Exception as exc:
raise ValueError(f"Error rendering prompt {self.name}) from exc

Check failure on line 213 in src/mcp/server/mcpserver/prompts/base.py

View check run for this annotation

Claude / Claude Code Review

Unterminated f-string: `raise ValueError(f"Error rendering prompt {self.name}) from exc` is missing the closing quote (and drops the paren placement), making the file invalid Python — a SyntaxError at import time.

Unterminated f-string: `raise ValueError(f"Error rendering prompt {self.name}) from exc` is missing the closing quote (and drops the paren placement), making the file invalid Python — a SyntaxError at import time.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P0: This line has an unterminated f-string, so importing mcp.server.mcpserver.prompts.base fails with a SyntaxError and prevents the server from starting. Close the string before the closing parenthesis.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/server/mcpserver/prompts/base.py, line 213:

<comment>This line has an unterminated f-string, so importing `mcp.server.mcpserver.prompts.base` fails with a `SyntaxError` and prevents the server from starting. Close the string before the closing parenthesis.</comment>

<file context>
@@ -210,4 +210,4 @@ async def render(
             raise
         except Exception as exc:
-            raise ValueError(f"Error rendering prompt {self.name}: {exc}") from exc
+            raise ValueError(f"Error rendering prompt {self.name}) from exc
</file context>
Suggested change
raise ValueError(f"Error rendering prompt {self.name}) from exc
raise ValueError(f"Error rendering prompt {self.name}") from exc

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Unterminated f-string: raise ValueError(f"Error rendering prompt {self.name}) from exc is missing the closing quote (and drops the paren placement), making the file invalid Python — a SyntaxError at import time.

Extended reasoning...

Any import mcp.server.mcpserver (or importing MCPServer at all, since server.py imports Prompt from prompts) raises SyntaxError: unterminated string literal at prompts/base.py:213. Every server using the SDK fails to start and the entire test suite errors at collection. The intended line is presumably raise ValueError(f"Error rendering prompt {self.name}") from exc — note the rewrite also drops the original : {e} detail from the message, so if that detail is meant to survive for get_prompt clients the fix should restore it too.

Verification: normal — the diff introduces a genuine SyntaxError. Line 213 of /home/claude/python-sdk/src/mcp/server/mcpserver/prompts/base.py now reads exactly: raise ValueError(f"Error rendering prompt {self.name}) from exc — the f-string opened with " is never closed, so ) from exc sits inside the string literal and the line ends with an unterminated string. The diff confirms this replaced the previo

12 changes: 5 additions & 7 deletions src/mcp/server/mcpserver/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,15 @@
from mcp_types import Annotations, Icon, InputRequiredResult
from pydantic import BaseModel, Field, validate_call

from mcp.server.mcpserver.exceptions import ResourceError
from mcp.server.mcpserver.exceptions import ResourceError, UnexpectedResourceError
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
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.logging import get_logger
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError
from mcp.shared.path_security import contains_path_traversal, is_absolute_path
from mcp.shared.uri_template import UriTemplate

logger = get_logger(__name__)

if TYPE_CHECKING:
from mcp.server.context import LifespanContextT, RequestT
from mcp.server.mcpserver.context import Context
Expand Down Expand Up @@ -217,7 +214,9 @@ async def create_resource(
carrying the echoed opaque state.

Raises:
ResourceError: If creating the resource fails.
ResourceError: If the template function raises `ResourceError`.
UnexpectedResourceError: If the template function raises anything other
than `ResourceError` or `MCPError`. `__cause__` is the original exception.
"""
try:
# Add context to params if needed
Expand Down Expand Up @@ -246,5 +245,4 @@ async def create_resource(
except (ResourceError, MCPError):
raise
except Exception as exc:
logger.exception(f"Error creating resource from template {uri}")
raise ResourceError(f"Error creating resource from template {uri}") from exc
raise UnexpectedResourceError(f"Error creating resource from template {uri}") from exc
Comment thread
maxisbey marked this conversation as resolved.
77 changes: 31 additions & 46 deletions src/mcp/server/mcpserver/resources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@

from mcp.server.mcpserver.resources.base import Resource
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError

# `application/*` types that are textual but predate the `+json`/`+xml`
# structured-syntax suffixes, so the suffix rule below can't catch them.
Expand Down Expand Up @@ -80,33 +79,28 @@ class FunctionResource(Resource):

async def read(self) -> str | bytes:
"""Read the resource by calling the wrapped function."""
try:
fn = self.fn
if is_async_callable(fn):
result = await fn()
else:
result = await anyio.to_thread.run_sync(self.fn)

if isinstance(result, InputRequiredResult):
# A static resource function can never read the retry's
# input_responses (it takes no Context), so this can only be a
# mistake — reject it instead of JSON-dumping it as content.
raise ValueError(
"static resources cannot return InputRequiredResult; only resource "
"template functions participate in the multi-round-trip flow"
)
if isinstance(result, Resource): # pragma: no cover
return await result.read()
elif isinstance(result, bytes):
return result
elif isinstance(result, str):
return result
else:
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
except MCPError:
raise
except Exception as e:
raise ValueError(f"Error reading resource {self.uri}: {e}")
fn = self.fn
if is_async_callable(fn):
result = await fn()
else:
result = await anyio.to_thread.run_sync(self.fn)

if isinstance(result, InputRequiredResult):
# A static resource function can never read the retry's
# input_responses (it takes no Context), so this can only be a
# mistake — reject it instead of JSON-dumping it as content.
raise ValueError(
"static resources cannot return InputRequiredResult; only resource "
"template functions participate in the multi-round-trip flow"
)
if isinstance(result, Resource): # pragma: no cover
return await result.read()
elif isinstance(result, bytes):
return result
elif isinstance(result, str):
return result
else:
return pydantic_core.to_json(result, fallback=str, indent=2).decode()

@classmethod
def from_function(
Expand Down Expand Up @@ -183,12 +177,9 @@ def validate_text_encoding(cls, encoding: str | None) -> str | None:

async def read(self) -> str | bytes:
"""Read the file content."""
try:
if self.encoding is None:
return await anyio.to_thread.run_sync(self.path.read_bytes)
return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))
except Exception as e:
raise ValueError(f"Error reading file {self.path}: {e}")
if self.encoding is None:
return await anyio.to_thread.run_sync(self.path.read_bytes)
return await anyio.to_thread.run_sync(partial(self.path.read_text, encoding=self.encoding))


class HttpResource(Resource):
Expand Down Expand Up @@ -228,18 +219,12 @@ def list_files(self) -> list[Path]: # pragma: no cover
if not self.path.is_dir():
raise NotADirectoryError(f"Not a directory: {self.path}")

try:
if self.pattern:
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))
except Exception as e:
raise ValueError(f"Error listing directory {self.path}: {e}")
if self.pattern:
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))

async def read(self) -> str: # Always returns JSON string # pragma: no cover
"""Read the directory listing."""
try:
files = await anyio.to_thread.run_sync(self.list_files)
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
return json.dumps({"files": file_list}, indent=2)
except Exception as e:
raise ValueError(f"Error reading directory {self.path}: {e}")
files = await anyio.to_thread.run_sync(self.list_files)
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
return json.dumps({"files": file_list}, indent=2)
Loading
Loading