|
5 | 5 |
|
6 | 6 | Contract: |
7 | 7 | - MCP_CONFORMANCE_SCENARIO env var -> scenario name |
8 | | - - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios) |
| 8 | + - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for auth scenarios) |
9 | 9 | - Server URL as last CLI argument (sys.argv[1]) |
10 | 10 | - Must exit 0 within 30 seconds |
11 | 11 |
|
|
16 | 16 | elicitation-sep1034-client-defaults - Elicitation with default accept callback |
17 | 17 | auth/client-credentials-jwt - Client credentials with private_key_jwt |
18 | 18 | auth/client-credentials-basic - Client credentials with client_secret_basic |
19 | | - auth/enterprise-token-exchange - Enterprise auth with OIDC ID token (SEP-990) |
20 | | - auth/enterprise-saml-exchange - Enterprise auth with SAML assertion (SEP-990) |
21 | | - auth/enterprise-id-jag-validation - Validate ID-JAG token structure (SEP-990) |
| 19 | + auth/cross-app-access-complete-flow - Enterprise managed OAuth (SEP-990) - v0.1.14+ |
| 20 | + auth/enterprise-token-exchange - Enterprise auth with OIDC ID token (legacy name) |
| 21 | + auth/enterprise-saml-exchange - Enterprise auth with SAML assertion (legacy name) |
| 22 | + auth/enterprise-id-jag-validation - Validate ID-JAG token structure (legacy name) |
22 | 23 | auth/* - Authorization code flow (default for auth scenarios) |
| 24 | +
|
| 25 | +Enterprise Auth (SEP-990): |
| 26 | + The conformance package v0.1.14+ (https://github.com/modelcontextprotocol/conformance/pull/110) |
| 27 | + provides the scenario 'auth/cross-app-access-complete-flow' which tests the complete |
| 28 | + enterprise managed OAuth flow: IDP ID token → ID-JAG → access token. |
| 29 | +
|
| 30 | + The client receives test context (idp_id_token, idp_token_endpoint, etc.) via |
| 31 | + MCP_CONFORMANCE_CONTEXT environment variable and performs the token exchange flows automatically. |
23 | 32 | """ |
24 | 33 |
|
25 | 34 | import asyncio |
@@ -296,9 +305,98 @@ async def run_auth_code_client(server_url: str) -> None: |
296 | 305 | await _run_auth_session(server_url, oauth_auth) |
297 | 306 |
|
298 | 307 |
|
| 308 | +@register("auth/cross-app-access-complete-flow") |
| 309 | +async def run_cross_app_access_complete_flow(server_url: str) -> None: |
| 310 | + """Enterprise managed auth: Complete SEP-990 flow (OIDC ID token → ID-JAG → access token). |
| 311 | +
|
| 312 | + This scenario is provided by @modelcontextprotocol/conformance@0.1.14+ (PR #110). |
| 313 | + It tests the complete enterprise managed OAuth flow using token exchange (RFC 8693) |
| 314 | + and JWT bearer grant (RFC 7523). |
| 315 | + """ |
| 316 | + from mcp.client.auth.extensions.enterprise_managed_auth import ( |
| 317 | + EnterpriseAuthOAuthClientProvider, |
| 318 | + TokenExchangeParameters, |
| 319 | + ) |
| 320 | + |
| 321 | + context = get_conformance_context() |
| 322 | + # The conformance package provides these fields |
| 323 | + idp_id_token = context.get("idp_id_token") |
| 324 | + idp_token_endpoint = context.get("idp_token_endpoint") |
| 325 | + idp_issuer = context.get("idp_issuer") |
| 326 | + |
| 327 | + # For cross-app access, we need to determine the MCP server's resource ID and auth issuer |
| 328 | + # The conformance package sets up the auth server, and the MCP server URL is passed to us |
| 329 | + |
| 330 | + if not idp_id_token: |
| 331 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'") |
| 332 | + if not idp_token_endpoint: |
| 333 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'") |
| 334 | + if not idp_issuer: |
| 335 | + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_issuer'") |
| 336 | + |
| 337 | + # Extract base URL and construct auth issuer and resource ID |
| 338 | + # The conformance test sets up auth server at a known location |
| 339 | + base_url = server_url.replace("/mcp", "") |
| 340 | + auth_issuer = context.get("auth_issuer", base_url) |
| 341 | + resource_id = context.get("resource_id", server_url) |
| 342 | + |
| 343 | + logger.debug(f"Cross-app access flow:") |
| 344 | + logger.debug(f" IDP Issuer: {idp_issuer}") |
| 345 | + logger.debug(f" IDP Token Endpoint: {idp_token_endpoint}") |
| 346 | + logger.debug(f" Auth Issuer: {auth_issuer}") |
| 347 | + logger.debug(f" Resource ID: {resource_id}") |
| 348 | + |
| 349 | + # Create token exchange parameters from IDP ID token |
| 350 | + token_exchange_params = TokenExchangeParameters.from_id_token( |
| 351 | + id_token=idp_id_token, |
| 352 | + mcp_server_auth_issuer=auth_issuer, |
| 353 | + mcp_server_resource_id=resource_id, |
| 354 | + scope=context.get("scope"), |
| 355 | + ) |
| 356 | + |
| 357 | + # Get pre-configured client credentials from context (if provided) |
| 358 | + client_id = context.get("client_id") |
| 359 | + client_secret = context.get("client_secret") |
| 360 | + |
| 361 | + # Create storage and pre-configure client info if credentials are provided |
| 362 | + storage = InMemoryTokenStorage() |
| 363 | + |
| 364 | + # Create enterprise auth provider |
| 365 | + enterprise_auth = EnterpriseAuthOAuthClientProvider( |
| 366 | + server_url=server_url, |
| 367 | + client_metadata=OAuthClientMetadata( |
| 368 | + client_name="conformance-cross-app-client", |
| 369 | + redirect_uris=[AnyUrl("http://localhost:3000/callback")], |
| 370 | + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], |
| 371 | + response_types=["token"], |
| 372 | + ), |
| 373 | + storage=storage, |
| 374 | + idp_token_endpoint=idp_token_endpoint, |
| 375 | + token_exchange_params=token_exchange_params, |
| 376 | + ) |
| 377 | + |
| 378 | + # If client credentials are provided in context, use them instead of dynamic registration |
| 379 | + if client_id and client_secret: |
| 380 | + from mcp.shared.auth import OAuthClientInformationFull |
| 381 | + |
| 382 | + logger.debug(f"Using pre-configured client credentials: {client_id}") |
| 383 | + client_info = OAuthClientInformationFull( |
| 384 | + client_id=client_id, |
| 385 | + client_secret=client_secret, |
| 386 | + token_endpoint_auth_method="client_secret_basic", |
| 387 | + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], |
| 388 | + response_types=["token"], |
| 389 | + redirect_uris=[AnyUrl("http://localhost:3000/callback")], |
| 390 | + ) |
| 391 | + enterprise_auth.context.client_info = client_info |
| 392 | + await storage.set_client_info(client_info) |
| 393 | + |
| 394 | + await _run_auth_session(server_url, enterprise_auth) |
| 395 | + |
| 396 | + |
299 | 397 | @register("auth/enterprise-token-exchange") |
300 | 398 | async def run_enterprise_token_exchange(server_url: str) -> None: |
301 | | - """Enterprise managed auth: Token exchange flow (RFC 8693).""" |
| 399 | + """Enterprise managed auth: Token exchange flow (RFC 8693) with OIDC ID token.""" |
302 | 400 | from mcp.client.auth.extensions.enterprise_managed_auth import ( |
303 | 401 | EnterpriseAuthOAuthClientProvider, |
304 | 402 | TokenExchangeParameters, |
@@ -342,51 +440,12 @@ async def run_enterprise_token_exchange(server_url: str) -> None: |
342 | 440 | token_exchange_params=token_exchange_params, |
343 | 441 | ) |
344 | 442 |
|
345 | | - # Perform token exchange flow |
346 | | - async with httpx.AsyncClient() as client: |
347 | | - # Step 1: Set OAuth metadata manually (since we're not going through full OAuth flow) |
348 | | - logger.debug(f"Setting OAuth metadata for {server_url}") |
349 | | - from pydantic import AnyUrl as PydanticAnyUrl |
350 | | - |
351 | | - from mcp.shared.auth import OAuthMetadata |
352 | | - |
353 | | - # Extract base URL from server_url |
354 | | - base_url = server_url.replace("/mcp", "") |
355 | | - token_endpoint_url = f"{base_url}/oauth/token" |
356 | | - auth_endpoint_url = f"{base_url}/oauth/authorize" |
357 | | - |
358 | | - enterprise_auth.context.oauth_metadata = OAuthMetadata( |
359 | | - issuer=mcp_server_auth_issuer, |
360 | | - authorization_endpoint=PydanticAnyUrl(auth_endpoint_url), |
361 | | - token_endpoint=PydanticAnyUrl(token_endpoint_url), |
362 | | - ) |
363 | | - logger.debug(f"OAuth metadata set, token_endpoint: {token_endpoint_url}") |
364 | | - |
365 | | - # Step 2: Exchange ID token for ID-JAG |
366 | | - logger.debug("Exchanging ID token for ID-JAG") |
367 | | - id_jag = await enterprise_auth.exchange_token_for_id_jag(client) |
368 | | - logger.debug(f"Obtained ID-JAG: {id_jag[:50]}...") |
369 | | - |
370 | | - # Step 3: Exchange ID-JAG for access token |
371 | | - logger.debug("Exchanging ID-JAG for access token") |
372 | | - access_token = await enterprise_auth.exchange_id_jag_for_access_token(client, id_jag) |
373 | | - logger.debug(f"Obtained access token, expires in: {access_token.expires_in}s") |
374 | | - |
375 | | - # Step 4: Verify we can make authenticated requests |
376 | | - logger.debug("Verifying access token with MCP endpoint") |
377 | | - auth_client = httpx.AsyncClient(headers={"Authorization": f"Bearer {access_token.access_token}"}) |
378 | | - response = await auth_client.get(server_url.replace("/mcp", "") + "/mcp") |
379 | | - if response.status_code == 200: |
380 | | - logger.debug(f"Successfully authenticated with MCP server: {response.json()}") |
381 | | - else: |
382 | | - logger.warning(f"MCP server returned {response.status_code}") |
383 | | - |
384 | | - logger.debug("Enterprise auth flow completed successfully") |
| 443 | + await _run_auth_session(server_url, enterprise_auth) |
385 | 444 |
|
386 | 445 |
|
387 | 446 | @register("auth/enterprise-saml-exchange") |
388 | 447 | async def run_enterprise_saml_exchange(server_url: str) -> None: |
389 | | - """Enterprise managed auth: SAML assertion exchange flow.""" |
| 448 | + """Enterprise managed auth: SAML assertion exchange flow (RFC 8693).""" |
390 | 449 | from mcp.client.auth.extensions.enterprise_managed_auth import ( |
391 | 450 | EnterpriseAuthOAuthClientProvider, |
392 | 451 | TokenExchangeParameters, |
@@ -430,51 +489,12 @@ async def run_enterprise_saml_exchange(server_url: str) -> None: |
430 | 489 | token_exchange_params=token_exchange_params, |
431 | 490 | ) |
432 | 491 |
|
433 | | - # Perform token exchange flow |
434 | | - async with httpx.AsyncClient() as client: |
435 | | - # Step 1: Set OAuth metadata manually (since we're not going through full OAuth flow) |
436 | | - logger.debug(f"Setting OAuth metadata for {server_url}") |
437 | | - from pydantic import AnyUrl as PydanticAnyUrl |
438 | | - |
439 | | - from mcp.shared.auth import OAuthMetadata |
440 | | - |
441 | | - # Extract base URL from server_url |
442 | | - base_url = server_url.replace("/mcp", "") |
443 | | - token_endpoint_url = f"{base_url}/oauth/token" |
444 | | - auth_endpoint_url = f"{base_url}/oauth/authorize" |
445 | | - |
446 | | - enterprise_auth.context.oauth_metadata = OAuthMetadata( |
447 | | - issuer=mcp_server_auth_issuer, |
448 | | - authorization_endpoint=PydanticAnyUrl(auth_endpoint_url), |
449 | | - token_endpoint=PydanticAnyUrl(token_endpoint_url), |
450 | | - ) |
451 | | - logger.debug(f"OAuth metadata set, token_endpoint: {token_endpoint_url}") |
452 | | - |
453 | | - # Step 2: Exchange SAML assertion for ID-JAG |
454 | | - logger.debug("Exchanging SAML assertion for ID-JAG") |
455 | | - id_jag = await enterprise_auth.exchange_token_for_id_jag(client) |
456 | | - logger.debug(f"Obtained ID-JAG from SAML: {id_jag[:50]}...") |
457 | | - |
458 | | - # Step 3: Exchange ID-JAG for access token |
459 | | - logger.debug("Exchanging ID-JAG for access token") |
460 | | - access_token = await enterprise_auth.exchange_id_jag_for_access_token(client, id_jag) |
461 | | - logger.debug(f"Obtained access token, expires in: {access_token.expires_in}s") |
462 | | - |
463 | | - # Step 4: Verify we can make authenticated requests |
464 | | - logger.debug("Verifying access token with MCP endpoint") |
465 | | - auth_client = httpx.AsyncClient(headers={"Authorization": f"Bearer {access_token.access_token}"}) |
466 | | - response = await auth_client.get(server_url.replace("/mcp", "") + "/mcp") |
467 | | - if response.status_code == 200: |
468 | | - logger.debug(f"Successfully authenticated with MCP server: {response.json()}") |
469 | | - else: |
470 | | - logger.warning(f"MCP server returned {response.status_code}") |
471 | | - |
472 | | - logger.debug("SAML enterprise auth flow completed successfully") |
| 492 | + await _run_auth_session(server_url, enterprise_auth) |
473 | 493 |
|
474 | 494 |
|
475 | 495 | @register("auth/enterprise-id-jag-validation") |
476 | 496 | async def run_id_jag_validation(server_url: str) -> None: |
477 | | - """Validate ID-JAG token structure and claims.""" |
| 497 | + """Validate ID-JAG token structure and claims (SEP-990).""" |
478 | 498 | from mcp.client.auth.extensions.enterprise_managed_auth import ( |
479 | 499 | EnterpriseAuthOAuthClientProvider, |
480 | 500 | TokenExchangeParameters, |
@@ -528,7 +548,7 @@ async def run_id_jag_validation(server_url: str) -> None: |
528 | 548 | # Validate required claims |
529 | 549 | assert claims.typ == "oauth-id-jag+jwt", f"Invalid typ: {claims.typ}" |
530 | 550 | assert claims.jti, "Missing jti claim" |
531 | | - assert claims.iss == mcp_server_auth_issuer or claims.iss, "Missing or invalid iss claim" |
| 551 | + assert claims.iss, "Missing iss claim" |
532 | 552 | assert claims.sub, "Missing sub claim" |
533 | 553 | assert claims.aud, "Missing aud claim" |
534 | 554 | assert claims.resource == mcp_server_resource_id, f"Invalid resource: {claims.resource}" |
|
0 commit comments