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
2 changes: 2 additions & 0 deletions docs/handlers/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ went to standard error: the terminal, not the wire.
don't want log lines, you want spans. Your server already emits them: the SDK traces every
message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**.

You don't have to log your own handlers' crashes either. When a tool or resource function raises something unexpected, the SDK writes the `ERROR` record with the traceback for you, on its own `mcp.*` loggers; a failure you raised deliberately (`ToolError`, `ResourceNotFoundError`) is an `INFO` line instead. A prompt function that raises is an `ERROR` record too, whatever it raised. **[Handling errors](../servers/handling-errors.md#what-lands-in-your-log)** has the split. (In a test using `Client(mcp, raise_exceptions=True)`, a prompt failure is handed to your test as the exception rather than logged.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

this is too long and dense. Instead simply say that for unexpected errors or something go see here and point to the other page. this is waaaaaaaaaaaaaaaay to densely packed and weirdly worded.


## Recap

* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it.
Expand Down
2 changes: 1 addition & 1 deletion docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1016,7 +1016,7 @@ 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`).

Expand Down
30 changes: 25 additions & 5 deletions docs/servers/handling-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,29 @@ 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; the traceback is in the server's log (next
section), which pytest's `caplog` captures. **[Testing](../get-started/testing.md)** covers the pattern.

## What lands in your log

Your server keeps its own record of these failures, and it draws one more line: between a failure you anticipated and one you didn't.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

super weird wording


`get_author` raised a plain `ValueError`. The model got the message, but the SDK can't know you *meant* that exception, so it assumes you didn't: the call is logged at `ERROR` with the full traceback. That is exactly what you want on the day the exception is a `KeyError` from three libraries down 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"
```

The model reads precisely what it read before. The difference is on your side: a `ToolError` is logged as one `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 `INFO` lines too; those are the caller's mistakes, not yours.

Resources draw the same line. The `-32603` from a crashing resource handler names only the URI, so the `ERROR` record in your log is the one place the cause and its traceback exist. `ResourceNotFoundError`, including the SDK's own `Unknown resource`, is an `INFO` line. (A template parameter that fails its type annotation, `books://{id}` read with an `id` that isn't an `int`, currently counts as a crash.)

Prompts aren't split yet: any failure in a prompt function, including an unknown name or a missing argument, is one `ERROR` record with its traceback, written by the transport layer that turns it into the JSON-RPC error.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

remove this line, weird to include


## Recap

Expand All @@ -127,7 +146,8 @@ It means a whole class of `raise` statements you don't write: don't re-validate
* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`.
* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`.
* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those.
* `from mcp import MCPError`; the error-code constants come from `mcp.types`.
* In your log: an exception you didn't raise as `ToolError` is an `ERROR` record with its traceback; `ToolError`, bad tool arguments, unknown tool names, and `ResourceNotFoundError` are one `INFO` line each.
* `from mcp import MCPError`; `ToolError` and `ResourceNotFoundError` come from `mcp.server.mcpserver.exceptions`; the error-code constants come from `mcp.types`.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

why include this line, don't. also stop with the crazy amount of semi colons. I feel like you know you're not allowed to use emdashes, so instead you use semi colons. just use other gramatical structures that are more natural please


Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**.

Expand Down
9 changes: 5 additions & 4 deletions docs/servers/uri-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,11 @@ 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` from `mcp.server.mcpserver.exceptions`. The client gets `-32602` with
your message and the URI, and your log gets one `INFO` line; any other exception is treated as
a crash (`-32603`, and an `ERROR` record with the traceback). 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, the traceback is in the **server's log**: an exception the tool didn't raise as `ToolError` is logged there 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]
41 changes: 38 additions & 3 deletions src/mcp/server/mcpserver/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,55 @@ class MCPServerError(Exception):


class ResourceError(MCPServerError):
"""Error in resource operations."""
"""Error in resource operations.

When a resource or resource template handler raises this, its message reaches
the client as a `-32603` protocol error.
"""


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

Raise this from a resource template handler to signal that the requested instance does not exist;
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`.

MCPServer raises this itself, around a crash in a resource or resource
template handler or a failed file read; 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 the model should read.

Raise this from a tool (or a resolver) for a failure you anticipate: 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.

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`.

MCPServer 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 @@ -196,5 +196,5 @@ async def render(
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}: {exc}") from exc
14 changes: 7 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 @@ -218,7 +215,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.
"""
try:
# Add context to params if needed
Expand Down Expand Up @@ -247,5 +246,6 @@ 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
# Name only the URI: the original text is withheld from the client, and
# the server logs the traceback from `__cause__`.
raise UnexpectedResourceError(f"Error creating resource from template {uri}") from exc
28 changes: 18 additions & 10 deletions src/mcp/server/mcpserver/resources/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from mcp_types import Annotations, Icon, InputRequiredResult
from pydantic import Field, validate_call

from mcp.server.mcpserver.exceptions import ResourceError, UnexpectedResourceError
from mcp.server.mcpserver.resources.base import Resource
from mcp.shared._callable_inspection import is_async_callable
from mcp.shared.exceptions import MCPError
Expand Down Expand Up @@ -79,7 +80,12 @@ class FunctionResource(Resource):
fn: Callable[[], Any] = Field(exclude=True)

async def read(self) -> str | bytes:
"""Read the resource by calling the wrapped function."""
"""Read the resource by calling the wrapped function.

Raises:
UnexpectedResourceError: If the function raises anything other than
`ResourceError` or `MCPError`; `__cause__` is the original.
"""
try:
fn = self.fn
if is_async_callable(fn):
Expand All @@ -103,10 +109,12 @@ async def read(self) -> str | bytes:
return result
else:
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
except MCPError:
except (MCPError, ResourceError):
raise
except Exception as e:
raise ValueError(f"Error reading resource {self.uri}: {e}")
except Exception as exc:
# Name only the URI: the original text is withheld from the client, and
# the server logs the traceback from `__cause__`.
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc

@classmethod
def from_function(
Expand Down Expand Up @@ -187,8 +195,8 @@ async def read(self) -> str | bytes:
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}")
except Exception as exc:
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc


class HttpResource(Resource):
Expand Down Expand Up @@ -232,14 +240,14 @@ def list_files(self) -> list[Path]: # pragma: no cover
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}")
except Exception as exc:
raise ValueError(f"Error listing directory {self.path}: {exc}") from exc

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}")
except Exception as exc:
raise UnexpectedResourceError(f"Error reading resource {self.uri}") from exc
Loading
Loading