Add a client extension API (#3034)
This commit is contained in:
parent
7322ca56f4
commit
4df609119f
3 changed files with 3410 additions and 180 deletions
|
|
@ -18,6 +18,7 @@ from mcp_types import (
|
|||
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 (
|
||||
|
|
@ -26,7 +27,6 @@ from mcp.server.extension import (
|
|||
ResourceBinding,
|
||||
ToolBinding,
|
||||
compose_tool_call_interceptor,
|
||||
validate_extension_identifier,
|
||||
)
|
||||
from mcp.server.mcpserver import Context, MCPServer, require_client_extension
|
||||
from mcp.server.mcpserver.resources import TextResource
|
||||
|
|
@ -193,7 +193,7 @@ async def test_extension_method_reachable_via_session_send_request() -> None:
|
|||
|
||||
async with Client(server) as client:
|
||||
request = _PingRequest(params=_PingParams())
|
||||
result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult)
|
||||
result = await client.session.send_request(request, _PingResult)
|
||||
|
||||
assert result == snapshot(_PingResult(pong=True))
|
||||
|
||||
|
|
@ -343,7 +343,7 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None:
|
|||
|
||||
async with Client(server, mode="2026-07-28") as client:
|
||||
request = _VersionPinnedRequest(params=_VersionPinnedParams())
|
||||
result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
|
||||
result = await client.session.send_request(request, _VersionPinnedResult)
|
||||
|
||||
assert result == snapshot(_VersionPinnedResult(ok=True))
|
||||
|
||||
|
|
@ -356,56 +356,12 @@ async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version
|
|||
async with Client(server, mode="legacy") as client:
|
||||
request = _VersionPinnedRequest(params=_VersionPinnedParams())
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult)
|
||||
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(
|
||||
"identifier",
|
||||
[
|
||||
"io.modelcontextprotocol/ui",
|
||||
"com.example/my_ext",
|
||||
"com.x-y.z2/n.a-b_c",
|
||||
"example/x",
|
||||
"a/b",
|
||||
"com.example/9start",
|
||||
],
|
||||
)
|
||||
def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None:
|
||||
"""Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end,
|
||||
hyphens interior), a slash, then a name that starts and ends alphanumeric."""
|
||||
validate_extension_identifier(identifier, owner="T")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier",
|
||||
[
|
||||
"noprefix",
|
||||
"-foo/bar",
|
||||
".leading/x",
|
||||
"a..b/x",
|
||||
"foo-/x",
|
||||
"9foo/x",
|
||||
"foo/-bar",
|
||||
"foo/bar-",
|
||||
"foo/",
|
||||
"/bar",
|
||||
"foo/ba r",
|
||||
"io.modelcontextprotocol/ui\n",
|
||||
"",
|
||||
None,
|
||||
42,
|
||||
],
|
||||
)
|
||||
def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None:
|
||||
"""Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels)
|
||||
and malformed names are rejected, as are non-strings."""
|
||||
with pytest.raises(TypeError):
|
||||
validate_extension_identifier(identifier, owner="T")
|
||||
|
||||
|
||||
@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
|
||||
|
|
@ -466,7 +422,7 @@ 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={_NEEDS_EXT: {}}) as client:
|
||||
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"}))
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import pytest
|
|||
from inline_snapshot import snapshot
|
||||
from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents
|
||||
|
||||
from mcp.client import advertise
|
||||
from mcp.client.client import Client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.apps import (
|
||||
|
|
@ -95,7 +96,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No
|
|||
branching on `client_supports_apps(ctx)`, drives both halves."""
|
||||
server = _clock_server()
|
||||
|
||||
async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports:
|
||||
async with Client(server, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as supports:
|
||||
rich = await supports.call_tool("get_time", {})
|
||||
async with Client(server) as plain:
|
||||
fallback = await plain.call_tool("get_time", {})
|
||||
|
|
@ -104,7 +105,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No
|
|||
assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")])
|
||||
|
||||
|
||||
async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool:
|
||||
async def _observed_client_supports_apps(ui_settings: dict[str, Any] | None) -> bool:
|
||||
"""Run one probe `tools/call` and report what `client_supports_apps` saw server-side.
|
||||
|
||||
Exercises the lowlevel `ServerRequestContext` form, which reads the client's
|
||||
|
|
@ -123,29 +124,30 @@ async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] |
|
|||
return CallToolResult(content=[TextContent(text="ok")])
|
||||
|
||||
server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
extensions = None if ui_settings is None else [advertise(EXTENSION_ID, ui_settings)]
|
||||
async with Client(server, extensions=extensions) as client:
|
||||
await client.call_tool("probe", {})
|
||||
return observed[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("extensions", "expected"),
|
||||
("ui_settings", "expected"),
|
||||
[
|
||||
pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"),
|
||||
pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"),
|
||||
pytest.param({"mimeTypes": [APP_MIME_TYPE]}, True, id="html-mime-listed"),
|
||||
pytest.param({"mimeTypes": (APP_MIME_TYPE,)}, True, id="in-process-tuple-mime-types"),
|
||||
pytest.param(None, False, id="extension-not-declared"),
|
||||
pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"),
|
||||
pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"),
|
||||
pytest.param({"mimeTypes": ["application/x-other"]}, False, id="html-mime-not-offered"),
|
||||
pytest.param({}, False, id="mime-types-key-missing"),
|
||||
],
|
||||
)
|
||||
async def test_client_supports_apps_from_lowlevel_request_context(
|
||||
extensions: dict[str, dict[str, Any]] | None, expected: bool
|
||||
ui_settings: dict[str, Any] | None, expected: bool
|
||||
) -> None:
|
||||
"""ext-apps: `client_supports_apps` is `True` only when the client declared the ui
|
||||
extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings — a
|
||||
required field, so its absence means unsupported (the reference SDK's check is
|
||||
`uiCap?.mimeTypes?.includes(...)`)."""
|
||||
assert await _observed_client_supports_apps(extensions) is expected
|
||||
assert await _observed_client_supports_apps(ui_settings) is expected
|
||||
|
||||
|
||||
def test_apps_tool_rejects_non_ui_resource_uri() -> None:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import mcp_types as types
|
|||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
|
||||
from mcp.client import advertise
|
||||
from mcp.client.client import Client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.extension import Extension
|
||||
|
|
@ -82,7 +83,7 @@ async def test_server_accepts_capability_for_client_advertised_extension() -> No
|
|||
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
|
||||
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
|
||||
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
|
||||
await client.call_tool("probe", {})
|
||||
|
||||
assert supported == [True]
|
||||
|
|
@ -105,7 +106,7 @@ async def test_server_rejects_capability_for_undeclared_extension() -> None:
|
|||
return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools)
|
||||
async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client:
|
||||
async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client:
|
||||
await client.call_tool("probe", {})
|
||||
|
||||
assert supported == [False]
|
||||
|
|
|
|||
Loading…
Reference in a new issue