|
22 | 22 | http-standard-headers - Connect, call a tool (Mcp-* headers checked) |
23 | 23 | http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter) |
24 | 24 | elicitation-sep1034-client-defaults - Elicitation with default accept callback |
25 | | - sep-2322-client-request-state - Drive the manual MRTR retry surface |
| 25 | + sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322) |
26 | 26 | auth/client-credentials-jwt - Client credentials with private_key_jwt |
27 | 27 | auth/client-credentials-basic - Client credentials with client_secret_basic |
| 28 | + auth/enterprise-managed-authorization - SEP-990 ID-JAG (RFC 8693 + RFC 7523 jwt-bearer) |
28 | 29 | auth/* - Authorization code flow (default for auth scenarios) |
29 | 30 | """ |
30 | 31 |
|
|
48 | 49 | PrivateKeyJWTOAuthProvider, |
49 | 50 | SignedJWTParameters, |
50 | 51 | ) |
| 52 | +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider |
| 53 | +from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls |
51 | 54 | from mcp.client.client import Client |
52 | 55 | from mcp.client.context import ClientRequestContext |
53 | 56 | from mcp.client.streamable_http import streamable_http_client |
@@ -374,46 +377,28 @@ async def run_elicitation_defaults(server_url: str) -> None: |
374 | 377 |
|
375 | 378 | @register("sep-2322-client-request-state") |
376 | 379 | async def run_mrtr_client(server_url: str) -> None: |
377 | | - """Drive the manual MRTR retry surface against the SEP-2322 client mock. |
378 | | -
|
379 | | - The mock speaks the modern lifecycle (server/discover, no initialize) and |
380 | | - inspects the wire params of each tools/call round, so this exercises the |
381 | | - explicit allow_input_required=True path rather than an auto-loop: round 1 |
382 | | - receives an InputRequiredResult, the fixture fulfils the elicitation |
383 | | - locally, then round 2 retries with input_responses + the echoed |
384 | | - request_state. Passing request_state straight off the typed result -- a |
385 | | - str when the server sent one, None when it didn't -- lets the |
386 | | - serializer's exclude_none drop the key in the no-state case without a |
387 | | - branch here. The unrelated call between rounds proves MRTR params don't |
388 | | - leak across tools, and the no-result-type call must parse as a complete |
389 | | - CallToolResult with no retry. |
| 380 | + """Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop. |
| 381 | +
|
| 382 | + The mock inspects raw `tools/call` params, so registering an |
| 383 | + `elicitation_callback` and letting the driver run is enough to satisfy |
| 384 | + all five wire-shape checks: the driver echoes `request_state` byte-exact |
| 385 | + and omits it when the server sent none, every retry mints a fresh |
| 386 | + JSON-RPC id, the unrelated call between auto-loops carries no MRTR |
| 387 | + params, and the no-`resultType` response parses as a terminal |
| 388 | + `CallToolResult` so the driver never retries it. |
390 | 389 | """ |
391 | | - async with Client(server_url, mode=client_mode()) as client: |
392 | | - await client.list_tools() |
393 | | - confirm = {"confirm": types.ElicitResult(action="accept", content={"confirmed": True})} |
394 | 390 |
|
395 | | - r1 = await client.call_tool("test_mrtr_echo_state", {}, allow_input_required=True) |
396 | | - assert isinstance(r1, types.InputRequiredResult) |
397 | | - |
398 | | - await client.call_tool("test_mrtr_unrelated", {}) |
| 391 | + async def confirm( |
| 392 | + context: ClientRequestContext, params: types.ElicitRequestParams |
| 393 | + ) -> types.ElicitResult | types.ErrorData: |
| 394 | + return types.ElicitResult(action="accept", content={"confirmed": True}) |
399 | 395 |
|
400 | | - await client.call_tool( |
401 | | - "test_mrtr_echo_state", |
402 | | - {}, |
403 | | - input_responses=confirm, |
404 | | - request_state=r1.request_state, |
405 | | - allow_input_required=True, |
406 | | - ) |
| 396 | + async with Client(server_url, mode=client_mode(), elicitation_callback=confirm) as client: |
| 397 | + await client.list_tools() |
407 | 398 |
|
408 | | - r2 = await client.call_tool("test_mrtr_no_state", {}, allow_input_required=True) |
409 | | - assert isinstance(r2, types.InputRequiredResult) |
410 | | - await client.call_tool( |
411 | | - "test_mrtr_no_state", |
412 | | - {}, |
413 | | - input_responses=confirm, |
414 | | - request_state=r2.request_state, |
415 | | - allow_input_required=True, |
416 | | - ) |
| 399 | + await client.call_tool("test_mrtr_echo_state", {}) |
| 400 | + await client.call_tool("test_mrtr_unrelated", {}) |
| 401 | + await client.call_tool("test_mrtr_no_state", {}) |
417 | 402 |
|
418 | 403 | result = await client.call_tool("test_mrtr_no_result_type", {}) |
419 | 404 | assert isinstance(result, types.CallToolResult) |
@@ -472,6 +457,70 @@ async def run_client_credentials_basic(server_url: str) -> None: |
472 | 457 | await _run_auth_session(server_url, oauth_auth) |
473 | 458 |
|
474 | 459 |
|
| 460 | +@register("auth/enterprise-managed-authorization") |
| 461 | +async def run_enterprise_managed_authorization(server_url: str) -> None: |
| 462 | + """SEP-990 enterprise-managed authorization: RFC 8693 token-exchange at the |
| 463 | + enterprise IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP |
| 464 | + authorization server.""" |
| 465 | + context = get_conformance_context() |
| 466 | + client_id = context.get("client_id") |
| 467 | + client_secret = context.get("client_secret") |
| 468 | + idp_client_id = context.get("idp_client_id") |
| 469 | + idp_id_token = context.get("idp_id_token") |
| 470 | + idp_token_endpoint = context.get("idp_token_endpoint") |
| 471 | + |
| 472 | + if not client_id: |
| 473 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") |
| 474 | + if not client_secret: |
| 475 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") |
| 476 | + if not idp_client_id: |
| 477 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_client_id'") |
| 478 | + if not idp_id_token: |
| 479 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'") |
| 480 | + if not idp_token_endpoint: |
| 481 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'") |
| 482 | + |
| 483 | + # IdentityAssertionOAuthProvider takes the AS issuer as configuration (the |
| 484 | + # SEP-990 trust model: the resource server is never asked which AS to use). |
| 485 | + # The harness does not put the issuer in context, so for conformance we |
| 486 | + # learn it from the harness's PRM document (RFC 9728); production |
| 487 | + # deployments would supply it as static configuration instead. |
| 488 | + prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0] |
| 489 | + async with httpx.AsyncClient(timeout=30.0) as http: |
| 490 | + prm = (await http.get(prm_url)).raise_for_status().json() |
| 491 | + as_issuer = prm["authorization_servers"][0] |
| 492 | + |
| 493 | + async def fetch_id_jag(audience: str, resource: str) -> str: |
| 494 | + """Leg 1 - RFC 8693 token-exchange at the enterprise IdP.""" |
| 495 | + async with httpx.AsyncClient(timeout=30.0) as http: |
| 496 | + resp = await http.post( |
| 497 | + idp_token_endpoint, |
| 498 | + data={ |
| 499 | + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", |
| 500 | + "requested_token_type": "urn:ietf:params:oauth:token-type:id-jag", |
| 501 | + "subject_token": idp_id_token, |
| 502 | + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", |
| 503 | + "audience": audience, |
| 504 | + "resource": resource, |
| 505 | + "client_id": idp_client_id, |
| 506 | + }, |
| 507 | + ) |
| 508 | + resp.raise_for_status() |
| 509 | + return resp.json()["access_token"] |
| 510 | + |
| 511 | + oauth_auth = IdentityAssertionOAuthProvider( |
| 512 | + server_url=server_url, |
| 513 | + storage=InMemoryTokenStorage(), |
| 514 | + client_id=client_id, |
| 515 | + client_secret=client_secret, |
| 516 | + issuer=as_issuer, |
| 517 | + assertion_provider=fetch_id_jag, |
| 518 | + token_endpoint_auth_method="client_secret_basic", |
| 519 | + ) |
| 520 | + |
| 521 | + await _run_auth_session(server_url, oauth_auth) |
| 522 | + |
| 523 | + |
475 | 524 | async def run_auth_code_client(server_url: str) -> None: |
476 | 525 | """Authorization code flow (default for auth/* scenarios).""" |
477 | 526 | callback_handler = ConformanceOAuthCallbackHandler() |
@@ -514,7 +563,7 @@ async def run_auth_code_client(server_url: str) -> None: |
514 | 563 | await _run_auth_session(server_url, oauth_auth) |
515 | 564 |
|
516 | 565 |
|
517 | | -async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: |
| 566 | +async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None: |
518 | 567 | """Common session logic for all OAuth flows.""" |
519 | 568 | http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) |
520 | 569 | transport = streamable_http_client(url=server_url, http_client=http_client) |
|
0 commit comments