Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 0 additions & 26 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,32 +28,6 @@ client:
# branch takes the per-request _meta path.
- tools_call

# --- SEP-837 (application_type during DCR) ---
# The sep-837-application-type-present check only fires on 2026-version
# runs; the client omits application_type during Dynamic Client
# Registration, so every auth scenario that reaches DCR fails it on this
# leg (the same scenarios pass at their default version in the 2025 legs).
- auth/metadata-default
- auth/metadata-var1
- auth/metadata-var2
- auth/metadata-var3
- auth/scope-from-www-authenticate
- auth/scope-from-scopes-supported
- auth/scope-omitted-when-undefined
- auth/token-endpoint-auth-basic
- auth/token-endpoint-auth-post
- auth/token-endpoint-auth-none
- auth/offline-access-not-supported
# SEP-2468 (authorization response iss parameter) is implemented, but these
# 2026-introduced scenarios reach DCR and so still fail the application_type
# check above; they unblock with SEP-837, not SEP-2468.
- auth/iss-supported
- auth/iss-not-advertised
- auth/iss-supported-missing
- auth/iss-wrong-issuer
- auth/iss-unexpected
- auth/iss-normalized

# --- Auth scenarios cut short by the 2026 connection lifecycle ---
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
# mock rejects the MCP POST before the scope-escalation behaviour these
Expand Down
12 changes: 0 additions & 12 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,6 @@ client:
# SEP-2352 (authorization server migration): client does not re-register when
# PRM authorization_servers changes.
- auth/authorization-server-migration
# SEP-837 (application_type during DCR): the check fires on every non-legacy
# spec version (the default LATEST is 2026-07-28). The client omits
# application_type during Dynamic Client Registration, so every scenario that
# reaches DCR fails it. SEP-2468 iss validation is implemented, so these now
# fail only on the application_type check, not on iss.
- auth/offline-access-not-supported
- auth/iss-supported
- auth/iss-not-advertised
- auth/iss-supported-missing
- auth/iss-wrong-issuer
- auth/iss-unexpected
- auth/iss-normalized

# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
Expand Down
15 changes: 15 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,21 @@ If you relied on extra fields round-tripping through MCP types, move that data i

## New Features

### OAuth Dynamic Client Registration sends `application_type` (SEP-837)

`OAuthClientMetadata` now carries an `application_type` field that is sent during Dynamic Client Registration. It defaults to `"native"`, which suits MCP clients that use loopback redirect URIs (CLI and desktop apps); browser-based clients served from a non-local host should set it to `"web"`:

```python
from mcp.shared.auth import OAuthClientMetadata

client_metadata = OAuthClientMetadata(
redirect_uris=["https://app.example.com/callback"],
application_type="web",
)
```

Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter.

### 2025-11-25 and 2026-07-28 protocol fields modeled

`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@
# servers may also return additional types they support
response_types: list[str] = ["code"]
scope: str | None = None
# SEP-837: OIDC application_type. Defaults to "native" since MCP clients typically use
# loopback redirect URIs; set "web" for remote browser-based clients on a non-local host.
Comment thread
Kludex marked this conversation as resolved.
application_type: Literal["web", "native"] = "native"

Check failure on line 72 in src/mcp/shared/auth.py

View check run for this annotation

Claude / Claude Code Review

Server DCR handler drops client-supplied application_type, stores/echoes default 'native'

The new `application_type` field is shared with the server-side DCR path, but `RegistrationHandler.handle()` (src/mcp/server/auth/handlers/register.py:100-121) builds `OAuthClientInformationFull` from an explicit field allowlist that does not include it, so a client registering with `application_type="web"` is silently rewritten to the new default `"native"` in both the stored client record and the RFC 7591 registration response (and clients omitting the field also get `"native"` echoed, contrar
Comment on lines +70 to +72

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new application_type field is shared with the server-side DCR path, but RegistrationHandler.handle() (src/mcp/server/auth/handlers/register.py:100-121) builds OAuthClientInformationFull from an explicit field allowlist that does not include it, so a client registering with application_type="web" is silently rewritten to the new default "native" in both the stored client record and the RFC 7591 registration response (and clients omitting the field also get "native" echoed, contrary to the OIDC default of "web"). Forward application_type=client_metadata.application_type in the handler's passthrough block.

Extended reasoning...

What the bug is

This PR adds application_type: Literal["web", "native"] = "native" to OAuthClientMetadata (src/mcp/shared/auth.py:70-72). That model is shared with the server-side Dynamic Client Registration path: RegistrationHandler.handle() parses the inbound /register body into OAuthClientMetadata and then constructs the stored/returned OAuthClientInformationFull (which subclasses OAuthClientMetadata) by explicitly enumerating the passthrough fields at src/mcp/server/auth/handlers/register.py:100-121redirect_uris, token_endpoint_auth_method, grant_types, ..., software_version. application_type is not in that list, so the constructed client_info always falls back to the model default "native", regardless of what the registering client sent.

Code path / step-by-step proof

  1. A third-party client POSTs to /register with {"redirect_uris": ["https://app.example.com/callback"], "application_type": "web", ...}.
  2. RegistrationHandler.handle() validates the body via OAuthClientMetadata.model_validate_jsonclient_metadata.application_type == "web" (the value parses fine; this PR's Literal accepts it).
  3. The handler builds OAuthClientInformationFull(...) at register.py:100-121, listing every passthrough field by hand. application_type is omitted, so the inherited default kicks in: client_info.application_type == "native".
  4. client_info is stored via provider.register_client(client_info) — the persisted client record now says "native".
  5. The 201 response is rendered with PydanticJSONResponse(content=client_info), whose render uses model_dump_json(exclude_none=True). "native" is not None, so the wire response actively carries "application_type": "native" — misreporting the registered metadata, which RFC 7591 §3.2.1 expects to reflect the registered values. The same happens for clients that omit the field, which under the OIDC default should mean "web".

Why nothing prevents it and what the impact is

Before this PR the key simply wasn't modeled: an inbound application_type was an ignored extra field and the server neither stored nor echoed anything — which is fine. After this PR the server affirmatively rewrites an explicitly supplied value and echoes the wrong one. Nothing else in src/ consumes application_type, so there is no authorization/enforcement consequence inside the SDK today; the harm is an incorrect stored record handed to provider implementations and a misleading registration response that the registering client may persist (the SDK client itself stores the response into TokenStorage). RFC 7591 technically permits an AS to substitute requested metadata, but here the rewrite is an accidental drop introduced by adding the field to the shared request model without updating the server passthrough, not a policy decision.

How to fix

Add one line to the passthrough block in RegistrationHandler.handle():

            application_type=client_metadata.application_type,

so the stored and echoed OAuthClientInformationFull reflects what the client actually registered.


# these fields are currently unused, but we support & store them for potential
# future use
Expand Down
16 changes: 16 additions & 0 deletions tests/client/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for refactored OAuth client authentication implementation."""

import base64
import json
import time
from unittest import mock
from urllib.parse import parse_qs, quote, unquote, urlparse
Expand Down Expand Up @@ -1049,6 +1050,21 @@ def test_falls_back_when_metadata_has_no_registration_endpoint(self):
assert request.method == "POST"


def test_registration_request_sends_application_type():
"""SEP-837: the DCR body carries application_type, defaulting to native and overridable."""
redirect_uris: list[AnyUrl] = [AnyUrl("http://localhost:3000/callback")]

default_request = create_client_registration_request(
None, OAuthClientMetadata(redirect_uris=redirect_uris), "https://auth.example.com"
)
assert json.loads(default_request.content)["application_type"] == "native"

web_request = create_client_registration_request(
None, OAuthClientMetadata(redirect_uris=redirect_uris, application_type="web"), "https://auth.example.com"
)
assert json.loads(web_request.content)["application_type"] == "web"


class TestAuthFlow:
"""Test the auth flow in httpx."""

Expand Down
1 change: 1 addition & 0 deletions tests/interaction/auth/test_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ async def test_the_dcr_request_carries_the_client_metadata() -> None:
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "mcp",
"application_type": "native",
"client_name": "interaction-suite",
"software_id": "interaction-test-suite",
}
Expand Down
Loading