49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
442 lines
17 KiB
Python
442 lines
17 KiB
Python
"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring).
|
|
|
|
These exercise the closed set of extension contribution kinds - tools,
|
|
resources, request methods, and the single `tools/call` interceptor - through
|
|
the highest-level public surface (in-memory `Client`), plus the
|
|
`compose_tool_call_interceptor` helper directly.
|
|
"""
|
|
|
|
from typing import Any, Literal, cast
|
|
|
|
import mcp_types as types
|
|
import pytest
|
|
from inline_snapshot import snapshot
|
|
from mcp_types import (
|
|
METHOD_NOT_FOUND,
|
|
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
|
CallToolResult,
|
|
TextContent,
|
|
)
|
|
|
|
from mcp.client import advertise
|
|
from mcp.client.client import Client
|
|
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
|
from mcp.server.extension import (
|
|
Extension,
|
|
MethodBinding,
|
|
ResourceBinding,
|
|
ToolBinding,
|
|
compose_tool_call_interceptor,
|
|
)
|
|
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
|
|
from mcp.server.mcpserver.resources import TextResource
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
|
|
_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}}
|
|
|
|
|
|
class _AdditiveExt(Extension):
|
|
"""Override `tools()`/`resources()` only - a purely additive extension."""
|
|
|
|
identifier = "com.example/additive"
|
|
|
|
def tools(self):
|
|
def ping() -> str:
|
|
"""Reply with pong."""
|
|
return "pong"
|
|
|
|
return [ToolBinding(fn=ping, meta=_TOOL_META)]
|
|
|
|
def resources(self):
|
|
return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))]
|
|
|
|
|
|
class _SettingsExt(Extension):
|
|
"""Override `settings()` so the extension advertises a non-empty settings map."""
|
|
|
|
identifier = "com.example/settings"
|
|
|
|
def settings(self) -> dict[str, Any]:
|
|
return {"feature": {"enabled": True}}
|
|
|
|
|
|
class _PingParams(types.RequestParams):
|
|
pass
|
|
|
|
|
|
class _PingResult(types.Result):
|
|
pong: bool
|
|
|
|
|
|
class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]):
|
|
method: Literal["com.example/ping"] = "com.example/ping"
|
|
params: _PingParams
|
|
|
|
|
|
async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult:
|
|
"""The shared `com.example/ping` handler (dispatched by the reachability test)."""
|
|
return _PingResult(pong=True)
|
|
|
|
|
|
class _MethodExt(Extension):
|
|
"""Override `methods()` to serve a new vendor request verb."""
|
|
|
|
identifier = "com.example/method"
|
|
|
|
def methods(self) -> list[MethodBinding]:
|
|
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
|
|
|
|
|
|
class _ReplacingExt(Extension):
|
|
"""Override `intercept_tool_call()` to short-circuit with a fixed result."""
|
|
|
|
identifier = "com.example/replacing"
|
|
|
|
async def intercept_tool_call(
|
|
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
|
) -> HandlerResult:
|
|
return CallToolResult(content=[TextContent(type="text", text="intercepted")])
|
|
|
|
|
|
class _PassThroughExt(Extension):
|
|
"""Override `intercept_tool_call()` but always delegate to `call_next` unchanged."""
|
|
|
|
identifier = "com.example/passthrough"
|
|
|
|
async def intercept_tool_call(
|
|
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
|
) -> HandlerResult:
|
|
return await call_next(ctx)
|
|
|
|
|
|
class _DefaultExt(Extension):
|
|
"""Override nothing - relies on the base `intercept_tool_call` default (pass through)."""
|
|
|
|
identifier = "com.example/default"
|
|
|
|
|
|
class _RecordingExt(Extension):
|
|
"""Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through."""
|
|
|
|
def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None:
|
|
self.identifier = identifier
|
|
self._log = log
|
|
|
|
async def intercept_tool_call(
|
|
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
|
|
) -> HandlerResult:
|
|
self._log.append((self.identifier, params.name))
|
|
return await call_next(ctx)
|
|
|
|
|
|
def _echo(value: str) -> str:
|
|
"""Echo the input value (shared tool body across interceptor tests)."""
|
|
return value
|
|
|
|
|
|
async def test_additive_extension_registers_its_tool_and_resource() -> None:
|
|
"""SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both
|
|
through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's
|
|
`_meta` round-trips equal to the exact dict the binding carried (identity can't
|
|
hold - the value is JSON-serialized over the transport)."""
|
|
server = MCPServer("test", extensions=[_AdditiveExt()])
|
|
|
|
async with Client(server) as client:
|
|
tools = await client.list_tools()
|
|
resources = await client.list_resources()
|
|
called = await client.call_tool("ping", {})
|
|
|
|
assert [t.name for t in tools.tools] == ["ping"]
|
|
assert tools.tools[0].meta == _TOOL_META
|
|
assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"}))
|
|
assert resources == snapshot(
|
|
types.ListResourcesResult(
|
|
resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")]
|
|
)
|
|
)
|
|
|
|
|
|
async def test_extension_settings_advertised_under_server_capabilities() -> None:
|
|
"""SDK-defined: `settings()` rides `server/discover` and lands under
|
|
`server_capabilities.extensions[identifier]` on the modern (`auto`) path."""
|
|
server = MCPServer("test", extensions=[_SettingsExt()])
|
|
|
|
async with Client(server, mode="auto") as client:
|
|
extensions = client.server_capabilities.extensions
|
|
|
|
assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}})
|
|
|
|
|
|
async def test_extension_settings_dropped_on_legacy_handshake() -> None:
|
|
"""Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions`
|
|
field, so a legacy `initialize` handshake drops the advertised extension even
|
|
though the modern `auto` path carries it."""
|
|
server = MCPServer("test", extensions=[_SettingsExt()])
|
|
|
|
async with Client(server, mode="legacy") as client:
|
|
assert client.server_capabilities.extensions is None
|
|
|
|
|
|
def test_duplicate_extension_identifier_raises() -> None:
|
|
"""SDK-defined: registering two extensions with the same `identifier` is a
|
|
construction error."""
|
|
with pytest.raises(ValueError):
|
|
MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()])
|
|
|
|
|
|
async def test_extension_method_reachable_via_session_send_request() -> None:
|
|
"""SDK-defined: an `Extension` overriding `methods()` wires a new request verb
|
|
onto the low-level server, reachable through `client.session.send_request`."""
|
|
server = MCPServer("test", extensions=[_MethodExt()])
|
|
|
|
async with Client(server) as client:
|
|
request = _PingRequest(params=_PingParams())
|
|
result = await client.session.send_request(request, _PingResult)
|
|
|
|
assert result == snapshot(_PingResult(pong=True))
|
|
|
|
|
|
async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None:
|
|
"""SDK-defined: an extension whose `intercept_tool_call` delegates to
|
|
`call_next` does not alter the underlying tool's `CallToolResult`."""
|
|
server = MCPServer("test", extensions=[_PassThroughExt()])
|
|
server.tool(name="echo")(_echo)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("echo", {"value": "hi"})
|
|
|
|
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
|
|
|
|
|
|
async def test_short_circuiting_interceptor_replaces_tool_result() -> None:
|
|
"""SDK-defined: an extension that returns from `intercept_tool_call` without
|
|
calling `call_next` replaces the tool's result wholesale (the tool never runs)."""
|
|
server = MCPServer("test", extensions=[_ReplacingExt()])
|
|
server.tool(name="echo", structured_output=False)(_echo)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("echo", {"value": "hi"})
|
|
|
|
assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")]))
|
|
|
|
|
|
def test_plain_extension_installs_no_tool_call_interceptor() -> None:
|
|
"""SDK-defined: an extension that does not override `intercept_tool_call` adds no
|
|
middleware - the composed interceptor exists only when at least one extension
|
|
overrides it."""
|
|
baseline = len(MCPServer("test")._lowlevel_server.middleware)
|
|
server = MCPServer("test", extensions=[_AdditiveExt()])
|
|
|
|
assert len(server._lowlevel_server.middleware) == baseline
|
|
|
|
|
|
def test_overriding_extension_installs_one_tool_call_interceptor() -> None:
|
|
"""SDK-defined: an extension that overrides `intercept_tool_call` composes exactly
|
|
one additional `tools/call` middleware."""
|
|
baseline = len(MCPServer("test")._lowlevel_server.middleware)
|
|
server = MCPServer("test", extensions=[_ReplacingExt()])
|
|
|
|
assert len(server._lowlevel_server.middleware) == baseline + 1
|
|
|
|
|
|
async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None:
|
|
"""SDK-defined: an extension that does not override `intercept_tool_call` runs the
|
|
base-class default (pass through) when another extension forces the composed
|
|
middleware to exist, leaving the tool result untouched."""
|
|
server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()])
|
|
server.tool(name="echo")(_echo)
|
|
|
|
async with Client(server) as client:
|
|
result = await client.call_tool("echo", {"value": "hi"})
|
|
|
|
assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"}))
|
|
|
|
|
|
async def test_interceptors_run_in_registration_order_with_threaded_params() -> None:
|
|
"""SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so
|
|
two passing-through interceptors record in registration order, each seeing the
|
|
validated `tools/call` params (the real tool name)."""
|
|
log: list[tuple[str, str]] = []
|
|
server = MCPServer(
|
|
"test",
|
|
extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)],
|
|
)
|
|
server.tool(name="echo")(_echo)
|
|
|
|
async with Client(server) as client:
|
|
await client.call_tool("echo", {"value": "hi"})
|
|
|
|
assert log == [("com.example/first", "echo"), ("com.example/second", "echo")]
|
|
|
|
|
|
async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None:
|
|
"""SDK-defined: the composed middleware is a no-op for any method other than
|
|
`tools/call` - it forwards to `call_next` without touching the interceptors."""
|
|
sentinel = types.EmptyResult()
|
|
|
|
async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
|
|
return sentinel
|
|
|
|
middleware = compose_tool_call_interceptor([_ReplacingExt()])
|
|
ctx = ServerRequestContext(
|
|
session=cast("Any", None),
|
|
lifespan_context={},
|
|
protocol_version="2026-07-28",
|
|
method="tasks/get",
|
|
params={"taskId": "t-1"},
|
|
)
|
|
|
|
result = await middleware(ctx, call_next)
|
|
|
|
assert result is sentinel
|
|
|
|
|
|
def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None:
|
|
"""SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the
|
|
subclass is defined (a bare name with no prefix is a TypeError)."""
|
|
with pytest.raises(TypeError):
|
|
type("_BadExt", (Extension,), {"identifier": "noprefix"})
|
|
|
|
|
|
def test_extension_without_identifier_is_rejected_at_registration() -> None:
|
|
"""SDK-defined: a subclass that never sets `identifier` (neither class-level nor in
|
|
`__init__`) is rejected when the server applies it."""
|
|
|
|
class _NoIdExt(Extension):
|
|
pass
|
|
|
|
with pytest.raises(TypeError):
|
|
MCPServer("test", extensions=[_NoIdExt()])
|
|
|
|
|
|
class _VersionPinnedParams(types.RequestParams):
|
|
pass
|
|
|
|
|
|
class _VersionPinnedResult(types.Result):
|
|
ok: bool
|
|
|
|
|
|
class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]):
|
|
method: Literal["com.example/pinned"] = "com.example/pinned"
|
|
params: _VersionPinnedParams
|
|
|
|
|
|
class _VersionPinnedExt(Extension):
|
|
"""A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`."""
|
|
|
|
identifier = "com.example/pinned"
|
|
|
|
def methods(self):
|
|
async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult:
|
|
return _VersionPinnedResult(ok=True)
|
|
|
|
return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))]
|
|
|
|
|
|
async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
|
|
"""SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version
|
|
in the set."""
|
|
server = MCPServer("test", extensions=[_VersionPinnedExt()])
|
|
|
|
async with Client(server, mode="2026-07-28") as client:
|
|
request = _VersionPinnedRequest(params=_VersionPinnedParams())
|
|
result = await client.session.send_request(request, _VersionPinnedResult)
|
|
|
|
assert result == snapshot(_VersionPinnedResult(ok=True))
|
|
|
|
|
|
async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None:
|
|
"""SDK-defined: the same method at a version outside `protocol_versions` is rejected with
|
|
METHOD_NOT_FOUND, mirroring the spec's per-version boundary."""
|
|
server = MCPServer("test", extensions=[_VersionPinnedExt()])
|
|
|
|
async with Client(server, mode="legacy") as client:
|
|
request = _VersionPinnedRequest(params=_VersionPinnedParams())
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.session.send_request(request, _VersionPinnedResult)
|
|
|
|
assert exc_info.value.code == METHOD_NOT_FOUND
|
|
assert exc_info.value.error.data == "com.example/pinned"
|
|
|
|
|
|
@pytest.mark.parametrize("method", ["tools/list", "completion/complete"])
|
|
def test_method_binding_rejects_spec_methods(method: str) -> None:
|
|
"""SDK-defined: extension methods are additive — binding a spec-defined request method
|
|
would silently shadow (or be shadowed by) the server's own handler, so it is rejected
|
|
when the binding is constructed."""
|
|
with pytest.raises(ValueError):
|
|
MethodBinding(method, _PingParams, _pong_handler)
|
|
|
|
|
|
def test_method_binding_rejects_empty_protocol_versions() -> None:
|
|
"""SDK-defined: an empty `protocol_versions` set would make the method unreachable at
|
|
every version; `None` is the universal-version spelling."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset())
|
|
assert str(exc_info.value) == snapshot(
|
|
"MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could "
|
|
"never be served; use None to admit every version"
|
|
)
|
|
|
|
|
|
class _OtherMethodExt(Extension):
|
|
"""A second extension binding the same verb as `_MethodExt`."""
|
|
|
|
identifier = "com.example/other-method"
|
|
|
|
def methods(self) -> list[MethodBinding]:
|
|
return [MethodBinding("com.example/ping", _PingParams, _pong_handler)]
|
|
|
|
|
|
def test_colliding_extension_methods_are_rejected_at_registration() -> None:
|
|
"""SDK-defined: two extensions binding the same method would silently last-write-win;
|
|
the collision is rejected when the second extension is applied."""
|
|
with pytest.raises(ValueError) as exc_info:
|
|
MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()])
|
|
assert str(exc_info.value) == snapshot(
|
|
"Extension 'com.example/other-method' binds method 'com.example/ping', which is already "
|
|
"registered; extension methods are additive and cannot replace another handler"
|
|
)
|
|
|
|
|
|
_NEEDS_EXT = "com.example/needed"
|
|
|
|
|
|
class _RequiresExt(Extension):
|
|
"""A tool that requires the client to have declared `com.example/needed`."""
|
|
|
|
identifier = _NEEDS_EXT
|
|
|
|
def tools(self):
|
|
def guarded(ctx: Context) -> str:
|
|
require_client_extension(ctx.request_context, _NEEDS_EXT)
|
|
return "ok"
|
|
|
|
return [ToolBinding(fn=guarded)]
|
|
|
|
|
|
async def test_require_client_extension_passes_when_client_declared_it() -> None:
|
|
"""SDK-defined: `require_client_extension` is a no-op when the client advertised the id."""
|
|
server = MCPServer("test", extensions=[_RequiresExt()])
|
|
|
|
async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client:
|
|
result = await client.call_tool("guarded", {})
|
|
|
|
assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"}))
|
|
|
|
|
|
async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None:
|
|
"""SDK-defined: `require_client_extension` raises the -32021 missing-required-capability
|
|
error when the client did not advertise the id."""
|
|
server = MCPServer("test", extensions=[_RequiresExt()])
|
|
|
|
async with Client(server) as client:
|
|
with pytest.raises(MCPError) as exc_info:
|
|
await client.call_tool("guarded", {})
|
|
|
|
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
|
assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}})
|