4e0fc9ebcb
A custom argument validator that raises something other than ValidationError escaped Tool.run unwrapped, losing the "Error executing tool" prefix and the UnexpectedToolError type. It is now wrapped as a crash, and an MCPError raised there still passes through. A ResourceError (usually ResourceNotFoundError from ctx.read_resource) that escapes a tool body is now classified like a ToolError, since it is the same anticipated outcome resources/read logs at INFO. An UnexpectedResourceError escaping a tool stays a crash. MCPServer.read_resource is now the single place a resource crash is wrapped (plus create_resource for templates), so the built-in Resource types let the original exception propagate to direct callers. Also: trimmed raise-site comments in favour of the exception docstrings, reworded the ToolError and ResourceError docstrings, documented the FunctionResource/FileResource.read change in migration.md, corrected the uri-templates tip and example, and pinned the new cases in tests (including a wire test for ResourceNotFoundError from a static resource).
210 lines
7.7 KiB
Python
210 lines
7.7 KiB
Python
"""Resource interactions against MCPServer, driven through the public Client API."""
|
|
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from mcp_types import (
|
|
ErrorData,
|
|
ListResourcesResult,
|
|
ListResourceTemplatesResult,
|
|
ReadResourceResult,
|
|
Resource,
|
|
ResourceTemplate,
|
|
TextResourceContents,
|
|
)
|
|
|
|
from mcp import MCPError
|
|
from mcp.server.mcpserver import MCPServer
|
|
from mcp.server.mcpserver.exceptions import ResourceNotFoundError
|
|
from tests._stamp import Unstamp
|
|
from tests.interaction._connect import Connect
|
|
from tests.interaction._requirements import requirement
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
|
|
@requirement("mcpserver:resource:static")
|
|
async def test_read_static_resource(connect: Connect, unstamped: Unstamp) -> None:
|
|
"""A function registered for a fixed URI is served at that URI with its return value as text."""
|
|
mcp = MCPServer("library")
|
|
|
|
@mcp.resource("config://app")
|
|
def app_config() -> str:
|
|
"""The application configuration."""
|
|
return "theme = dark"
|
|
|
|
async with connect(mcp) as client:
|
|
result = await client.read_resource("config://app")
|
|
|
|
assert unstamped(result) == snapshot(
|
|
ReadResourceResult(
|
|
contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")]
|
|
)
|
|
)
|
|
|
|
|
|
@requirement("mcpserver:resource:static")
|
|
async def test_list_static_and_templated_resources(connect: Connect, unstamped: Unstamp) -> None:
|
|
"""Statically-registered resources appear in resources/list; templated ones only in templates/list.
|
|
|
|
The name and description are derived from the function name and docstring; the MIME type
|
|
defaults to text/plain.
|
|
"""
|
|
mcp = MCPServer("library")
|
|
|
|
@mcp.resource("config://app")
|
|
def app_config() -> str:
|
|
"""The application configuration."""
|
|
raise NotImplementedError # registered for listing only; never read
|
|
|
|
@mcp.resource("users://{user_id}/profile")
|
|
def user_profile(user_id: str) -> str:
|
|
"""A user's profile."""
|
|
raise NotImplementedError # registered for listing only; never read
|
|
|
|
async with connect(mcp) as client:
|
|
resources = await client.list_resources()
|
|
templates = await client.list_resource_templates()
|
|
|
|
assert unstamped(resources) == snapshot(
|
|
ListResourcesResult(
|
|
resources=[
|
|
Resource(
|
|
name="app_config",
|
|
uri="config://app",
|
|
description="The application configuration.",
|
|
mime_type="text/plain",
|
|
)
|
|
]
|
|
)
|
|
)
|
|
assert unstamped(templates) == snapshot(
|
|
ListResourceTemplatesResult(
|
|
resource_templates=[
|
|
ResourceTemplate(
|
|
name="user_profile",
|
|
uri_template="users://{user_id}/profile",
|
|
description="A user's profile.",
|
|
mime_type="text/plain",
|
|
)
|
|
]
|
|
)
|
|
)
|
|
|
|
|
|
@requirement("mcpserver:resource:template")
|
|
@requirement("resources:read:template-vars")
|
|
async def test_read_templated_resource(connect: Connect, unstamped: Unstamp) -> None:
|
|
"""Reading a URI that matches a registered template invokes the function with the extracted parameters."""
|
|
mcp = MCPServer("library")
|
|
|
|
@mcp.resource("users://{user_id}/profile")
|
|
def user_profile(user_id: str) -> str:
|
|
"""A user's profile."""
|
|
return f"profile for {user_id}"
|
|
|
|
async with connect(mcp) as client:
|
|
result = await client.read_resource("users://42/profile")
|
|
|
|
assert unstamped(result) == snapshot(
|
|
ReadResourceResult(
|
|
contents=[TextResourceContents(uri="users://42/profile", mime_type="text/plain", text="profile for 42")]
|
|
)
|
|
)
|
|
|
|
|
|
@requirement("mcpserver:resource:unknown-uri")
|
|
async def test_read_unknown_uri_is_error(connect: Connect) -> None:
|
|
"""Reading a URI that matches no registered resource fails with -32602 and the URI in data (SEP-2164)."""
|
|
mcp = MCPServer("library")
|
|
|
|
@mcp.resource("config://app")
|
|
def app_config() -> str:
|
|
"""A registered resource; the test reads a different URI."""
|
|
raise NotImplementedError
|
|
|
|
async with connect(mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.read_resource("config://missing")
|
|
|
|
assert exc_info.value.error == snapshot(
|
|
ErrorData(code=-32602, message="Unknown resource: config://missing", data={"uri": "config://missing"})
|
|
)
|
|
|
|
|
|
@requirement("mcpserver:resource:read-throws-surfaced")
|
|
async def test_resource_function_that_raises_is_surfaced_as_a_jsonrpc_error(connect: Connect) -> None:
|
|
"""An exception raised by a resource function reaches the caller as a JSON-RPC error.
|
|
|
|
MCPServer wraps the failure in a generic ResourceError that names only the URI, so the original
|
|
exception text is not leaked to the client. The wrapped exception surfaces as -32603 Internal error.
|
|
"""
|
|
mcp = MCPServer("library")
|
|
|
|
@mcp.resource("res://boom")
|
|
def boom() -> str:
|
|
raise RuntimeError("nope")
|
|
|
|
async with connect(mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.read_resource("res://boom")
|
|
|
|
assert exc_info.value.error == snapshot(
|
|
ErrorData(code=-32603, message="Error reading resource res://boom", data={"uri": "res://boom"})
|
|
)
|
|
|
|
|
|
@requirement("mcpserver:resource:static-not-found")
|
|
async def test_static_resource_function_raising_not_found_is_invalid_params(connect: Connect) -> None:
|
|
"""ResourceNotFoundError from a fixed-URI resource function reaches the caller as -32602 with its message.
|
|
|
|
A static resource can still be absent (a report not generated yet, a file that comes and goes),
|
|
and the handler's message passes through exactly as it does from a template function.
|
|
"""
|
|
mcp = MCPServer("library")
|
|
|
|
@mcp.resource("reports://latest")
|
|
def latest() -> str:
|
|
raise ResourceNotFoundError("no report has been generated yet")
|
|
|
|
async with connect(mcp) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.read_resource("reports://latest")
|
|
|
|
assert exc_info.value.error == snapshot(
|
|
ErrorData(code=-32602, message="no report has been generated yet", data={"uri": "reports://latest"})
|
|
)
|
|
|
|
|
|
@requirement("mcpserver:resource:duplicate-name")
|
|
async def test_registering_a_duplicate_resource_uri_warns_and_keeps_the_first(
|
|
connect: Connect, unstamped: Unstamp
|
|
) -> None:
|
|
"""Registering a second static resource at an already-used URI keeps the first registration.
|
|
|
|
The intended behaviour is rejection at registration time; MCPServer instead logs a warning
|
|
and discards the second registration (see the divergence note on the requirement). The two
|
|
registrations use different function names so the test does not redefine a name in this scope;
|
|
the resource decorator keys on the URI, not the function name.
|
|
"""
|
|
mcp = MCPServer("library")
|
|
|
|
@mcp.resource("config://app")
|
|
def config_first() -> str:
|
|
"""The first registration; this is the one that wins."""
|
|
return "first"
|
|
|
|
@mcp.resource("config://app")
|
|
def config_second() -> str:
|
|
"""Registered at a duplicate URI; the registration is discarded so this never runs."""
|
|
raise NotImplementedError
|
|
|
|
async with connect(mcp) as client:
|
|
listed = await client.list_resources()
|
|
result = await client.read_resource("config://app")
|
|
|
|
assert [resource.uri for resource in listed.resources] == ["config://app"]
|
|
assert listed.resources[0].name == "config_first"
|
|
assert unstamped(result) == snapshot(
|
|
ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")])
|
|
)
|