|
26 | 26 | ResourceBinding, |
27 | 27 | ToolBinding, |
28 | 28 | compose_tool_call_interceptor, |
| 29 | + validate_extension_identifier, |
29 | 30 | ) |
30 | 31 | from mcp.server.mcpserver import Context, MCPServer, require_client_extension |
31 | 32 | from mcp.server.mcpserver.resources import TextResource |
@@ -74,16 +75,18 @@ class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]): |
74 | 75 | params: _PingParams |
75 | 76 |
|
76 | 77 |
|
| 78 | +async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult: |
| 79 | + """The shared `com.example/ping` handler (dispatched by the reachability test).""" |
| 80 | + return _PingResult(pong=True) |
| 81 | + |
| 82 | + |
77 | 83 | class _MethodExt(Extension): |
78 | 84 | """Override `methods()` to serve a new vendor request verb.""" |
79 | 85 |
|
80 | 86 | identifier = "com.example/method" |
81 | 87 |
|
82 | | - def methods(self): |
83 | | - async def handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult: |
84 | | - return _PingResult(pong=True) |
85 | | - |
86 | | - return [MethodBinding("com.example/ping", _PingParams, handler)] |
| 88 | + def methods(self) -> list[MethodBinding]: |
| 89 | + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] |
87 | 90 |
|
88 | 91 |
|
89 | 92 | class _ReplacingExt(Extension): |
@@ -356,6 +359,91 @@ async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version |
356 | 359 | await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) |
357 | 360 |
|
358 | 361 | assert exc_info.value.code == METHOD_NOT_FOUND |
| 362 | + assert exc_info.value.error.data == "com.example/pinned" |
| 363 | + |
| 364 | + |
| 365 | +@pytest.mark.parametrize( |
| 366 | + "identifier", |
| 367 | + [ |
| 368 | + "io.modelcontextprotocol/ui", |
| 369 | + "com.example/my_ext", |
| 370 | + "com.x-y.z2/n.a-b_c", |
| 371 | + "example/x", |
| 372 | + "a/b", |
| 373 | + "com.example/9start", |
| 374 | + ], |
| 375 | +) |
| 376 | +def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None: |
| 377 | + """Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end, |
| 378 | + hyphens interior), a slash, then a name that starts and ends alphanumeric.""" |
| 379 | + validate_extension_identifier(identifier, owner="T") |
| 380 | + |
| 381 | + |
| 382 | +@pytest.mark.parametrize( |
| 383 | + "identifier", |
| 384 | + [ |
| 385 | + "noprefix", |
| 386 | + "-foo/bar", |
| 387 | + ".leading/x", |
| 388 | + "a..b/x", |
| 389 | + "foo-/x", |
| 390 | + "9foo/x", |
| 391 | + "foo/-bar", |
| 392 | + "foo/bar-", |
| 393 | + "foo/", |
| 394 | + "/bar", |
| 395 | + "foo/ba r", |
| 396 | + "io.modelcontextprotocol/ui\n", |
| 397 | + "", |
| 398 | + None, |
| 399 | + 42, |
| 400 | + ], |
| 401 | +) |
| 402 | +def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None: |
| 403 | + """Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels) |
| 404 | + and malformed names are rejected, as are non-strings.""" |
| 405 | + with pytest.raises(TypeError): |
| 406 | + validate_extension_identifier(identifier, owner="T") |
| 407 | + |
| 408 | + |
| 409 | +@pytest.mark.parametrize("method", ["tools/list", "completion/complete"]) |
| 410 | +def test_method_binding_rejects_spec_methods(method: str) -> None: |
| 411 | + """SDK-defined: extension methods are additive — binding a spec-defined request method |
| 412 | + would silently shadow (or be shadowed by) the server's own handler, so it is rejected |
| 413 | + when the binding is constructed.""" |
| 414 | + with pytest.raises(ValueError): |
| 415 | + MethodBinding(method, _PingParams, _pong_handler) |
| 416 | + |
| 417 | + |
| 418 | +def test_method_binding_rejects_empty_protocol_versions() -> None: |
| 419 | + """SDK-defined: an empty `protocol_versions` set would make the method unreachable at |
| 420 | + every version; `None` is the universal-version spelling.""" |
| 421 | + with pytest.raises(ValueError) as exc_info: |
| 422 | + MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset()) |
| 423 | + assert str(exc_info.value) == snapshot( |
| 424 | + "MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could " |
| 425 | + "never be served; use None to admit every version" |
| 426 | + ) |
| 427 | + |
| 428 | + |
| 429 | +class _OtherMethodExt(Extension): |
| 430 | + """A second extension binding the same verb as `_MethodExt`.""" |
| 431 | + |
| 432 | + identifier = "com.example/other-method" |
| 433 | + |
| 434 | + def methods(self) -> list[MethodBinding]: |
| 435 | + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] |
| 436 | + |
| 437 | + |
| 438 | +def test_colliding_extension_methods_are_rejected_at_registration() -> None: |
| 439 | + """SDK-defined: two extensions binding the same method would silently last-write-win; |
| 440 | + the collision is rejected when the second extension is applied.""" |
| 441 | + with pytest.raises(ValueError) as exc_info: |
| 442 | + MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()]) |
| 443 | + assert str(exc_info.value) == snapshot( |
| 444 | + "Extension 'com.example/other-method' binds method 'com.example/ping', which is already " |
| 445 | + "registered; extension methods are additive and cannot replace another handler" |
| 446 | + ) |
359 | 447 |
|
360 | 448 |
|
361 | 449 | _NEEDS_EXT = "com.example/needed" |
|
0 commit comments