Skip to content

Commit 2f8b657

Browse files
committed
Merge remote-tracking branch 'origin/main' into resolver-dependency-injection
2 parents f2106f5 + 4b51978 commit 2f8b657

144 files changed

Lines changed: 11377 additions & 556 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/actions/conformance/client.py

Lines changed: 87 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@
2222
http-standard-headers - Connect, call a tool (Mcp-* headers checked)
2323
http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter)
2424
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)
2626
auth/client-credentials-jwt - Client credentials with private_key_jwt
2727
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)
2829
auth/* - Authorization code flow (default for auth scenarios)
2930
"""
3031

@@ -48,6 +49,8 @@
4849
PrivateKeyJWTOAuthProvider,
4950
SignedJWTParameters,
5051
)
52+
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
53+
from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls
5154
from mcp.client.client import Client
5255
from mcp.client.context import ClientRequestContext
5356
from mcp.client.streamable_http import streamable_http_client
@@ -374,46 +377,28 @@ async def run_elicitation_defaults(server_url: str) -> None:
374377

375378
@register("sep-2322-client-request-state")
376379
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.
390389
"""
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})}
394390

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})
399395

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()
407398

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", {})
417402

418403
result = await client.call_tool("test_mrtr_no_result_type", {})
419404
assert isinstance(result, types.CallToolResult)
@@ -472,6 +457,70 @@ async def run_client_credentials_basic(server_url: str) -> None:
472457
await _run_auth_session(server_url, oauth_auth)
473458

474459

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+
475524
async def run_auth_code_client(server_url: str) -> None:
476525
"""Authorization code flow (default for auth/* scenarios)."""
477526
callback_handler = ConformanceOAuthCallbackHandler()
@@ -514,7 +563,7 @@ async def run_auth_code_client(server_url: str) -> None:
514563
await _run_auth_session(server_url, oauth_auth)
515564

516565

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:
518567
"""Common session logic for all OAuth flows."""
519568
http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
520569
transport = streamable_http_client(url=server_url, http_client=http_client)

.github/actions/conformance/expected-failures.2026-07-28.yml

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,9 @@
2020
# (the runner fails on stale entries), so the baseline burns down per
2121
# milestone.
2222

23-
client:
24-
[]
25-
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
26-
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28
27-
# (it is an extension scenario not carried into the 2026 wire), so it is
28-
# neither run nor evaluated on this leg.
23+
client: []
2924

3025
server:
31-
# The stateless 2026 path now reaches handlers for plain request/response
32-
# scenarios; tools-call-with-progress still fails because the stateless
33-
# server has no channel for server→client progress notifications.
34-
- tools-call-with-progress
3526
# SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline
3627
# cannot return InputRequiredResult from MCPServer yet (tools/call can).
3728
- input-required-result-non-tool-request

.github/actions/conformance/expected-failures.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,7 @@
1010
# scenarios start passing and MUST be removed from this list (the runner fails
1111
# on stale entries), so the baseline burns down per milestone.
1212

13-
client:
14-
# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
15-
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
16-
# client support for the token-exchange + JWT bearer flow.
17-
- auth/enterprise-managed-authorization
13+
client: []
1814

1915
server:
2016
# --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) ---

docs/advanced/apps.md

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
# MCP Apps
2+
3+
An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML
4+
document the host renders as an interactive surface.
5+
6+
Two parts, always two parts:
7+
8+
1. **A tool** that does the work and returns data, like any other tool.
9+
2. **A `ui://` resource** containing the HTML the host shows for it.
10+
11+
The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches
12+
it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's
13+
result into that iframe via `postMessage`. Your server never sends or receives any
14+
`ui/*` messages: that traffic is between the host and the iframe. You serve a tool
15+
and an HTML document; the host does the theater.
16+
17+
The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`).
18+
If [Extensions](extensions.md) are new to you, skim that page first. One minute,
19+
then come back.
20+
21+
## A clock with a face
22+
23+
```python title="server.py" hl_lines="18 21 29 31"
24+
--8<-- "docs_src/apps/tutorial001.py"
25+
```
26+
27+
Four moves:
28+
29+
* `Apps()`: one instance holds your UI-bound tools and their resources.
30+
* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the
31+
`_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title,
32+
description, ...) passes through.
33+
* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching
34+
resource, served as `text/html;profile=mcp-app`. That exact MIME type is what
35+
tells a host "this is an app, render it".
36+
* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises
37+
`io.modelcontextprotocol/ui` under `capabilities.extensions`.
38+
39+
The HTML itself listens for the host's `postMessage` and shows the result. For real
40+
apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps)
41+
browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`,
42+
`getHostContext`, and `onhostcontextchanged` instead of raw message events.
43+
44+
## Graceful degradation
45+
46+
Not every client renders apps. The spec is blunt about what that means for you:
47+
48+
> Tools **MUST** return a meaningful `content` array even when UI is available.
49+
50+
The model reads `content`; the iframe is for humans. A UI-capable host still feeds
51+
the text result to the model, and a text-only client gets *only* that. So the
52+
canonical pattern is one tool, two answers. Look at `get_time` again:
53+
54+
```python title="server.py" hl_lines="22-26"
55+
--8<-- "docs_src/apps/tutorial001.py"
56+
```
57+
58+
`client_supports_apps(ctx)` is `True` only when the client declared the
59+
`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app`
60+
in its `mimeTypes` settings. The field is required, so a client that omits it
61+
does not count. That is exactly what `main()` in the same file declares: the
62+
client half of the negotiation, and the rich answer comes back.
63+
64+
!!! warning
65+
Never return a placeholder like `"[Rendered UI]"` as the only content. If the
66+
fallback text is useless, the tool is useless to every text-only client and to
67+
the model itself. Write the sentence.
68+
69+
## Locking the iframe down
70+
71+
The resource side carries the security metadata: what the iframe may load, which
72+
browser permissions it wants, how it would like to be framed:
73+
74+
```python title="server.py" hl_lines="9 19-22"
75+
--8<-- "docs_src/apps/tutorial002.py"
76+
```
77+
78+
`csp` and `permissions` are **requests to the host**, not server behaviour. The host
79+
builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it
80+
may refuse. Feature-detect in your JS rather than assuming a grant.
81+
82+
`ResourceCsp`, field by field (Python name, wire key, what the host does with it):
83+
84+
| Python | Wire (`_meta.ui.csp`) | Controls |
85+
|---|---|---|
86+
| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go |
87+
| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets |
88+
| `frame_domains` | `frameDomains` | `frame-src`: nested iframes |
89+
| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `<base>` may point at |
90+
91+
`ResourcePermissions`: each field requests a browser permission for the iframe.
92+
93+
| Python | Wire (`_meta.ui.permissions`) |
94+
|---|---|
95+
| `camera` | `camera` |
96+
| `microphone` | `microphone` |
97+
| `geolocation` | `geolocation` |
98+
| `clipboard_write` | `clipboardWrite` |
99+
100+
!!! note
101+
CSP and permissions live on the **resource**, never on the tool. The spec's tool
102+
metadata has no slot for them, and hosts ignore them there. The SDK makes the
103+
mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter.
104+
105+
### Visibility
106+
107+
`visibility=["app"]` on a tool says "this exists for the iframe, not the model":
108+
109+
* `"model"`: the model may call it.
110+
* `"app"`: the iframe may call it (via `callServerTool`).
111+
* Omitted: both, which is the default.
112+
113+
Filtering is the **host's** job. Your server lists app-only tools in `tools/list`
114+
like any other; the host hides them from the model. Don't filter server-side.
115+
116+
## The rules the SDK enforces
117+
118+
All of these fail at startup, not in production:
119+
120+
* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at
121+
decoration/registration time.
122+
* A tool bound to a URI with **no matching registered resource** is a `ValueError`
123+
when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising
124+
HTML that 404s on `resources/read` is a misconfiguration, so it refuses to
125+
construct.
126+
* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns
127+
`_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys
128+
merge fine alongside.
129+
130+
Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd
131+
rather you find out before a host does.
132+
133+
## Beyond inline HTML
134+
135+
`add_html_resource` covers the common case: a string of HTML. For anything else,
136+
HTML on disk or generated content, build the resource yourself and hand it over:
137+
138+
```python title="server.py" hl_lines="12 18"
139+
--8<-- "docs_src/apps/tutorial003.py"
140+
```
141+
142+
`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource
143+
doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource
144+
under any other MIME type is one no host will render.
145+
146+
!!! tip
147+
Targeting a pre-GA host that still reads the deprecated flat
148+
`_meta["ui/resourceUri"]` key? Merge it yourself:
149+
`@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`.
150+
The nested `ui` object is the spec shape; the flat key is on its way out.
151+
152+
## See it run
153+
154+
The `apps` story in `examples/stories/` is this page as a runnable pair: a server
155+
with a UI-bound clock tool and a client that negotiates Apps, reads the tool's
156+
`_meta.ui.resourceUri`, fetches the HTML, and calls the tool.
157+
158+
```bash
159+
uv run python -m stories.apps.client
160+
```

docs/advanced/authorization.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD
109109
server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec
110110
is built around. New servers should not reach for it.
111111

112+
An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **Identity assertion**.
113+
112114
## Recap
113115

114116
* Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them.

0 commit comments

Comments
 (0)