Skip to content

Commit f1ef2ec

Browse files
feat: specify OIDC application_type during dynamic client registration (SEP-837) (#883)
* feat(auth): specify OIDC application_type during client registration SEP-837 [1] requires an MCP client to specify an application_type during OIDC Dynamic Client Registration. When it is omitted, OIDC servers default the client to "web", which conflicts with the loopback redirect URIs that CLI and desktop clients use, so the registration can be rejected. I make register_client always send an application_type. It defaults to "native" to match the loopback redirect this SDK uses, and I added OAuthClientConfig::with_application_type so web clients can opt in. Tests cover the serialized request body and the config default. Implements [2]. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L395 [2]: #880 Signed-off-by: Stefano Amorelli <[email protected]> * chore(auth): declare application_type in client metadata document I set application_type to "native" in the hosted client metadata document so the URL-based client id flow and dynamic registration agree on the client type that SEP-837 [1] expects. [1]: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/draft/basic/authorization.mdx#L395 Signed-off-by: Stefano Amorelli <[email protected]> --------- Signed-off-by: Stefano Amorelli <[email protected]>
1 parent 82b04a3 commit f1ef2ec

2 files changed

Lines changed: 81 additions & 2 deletions

File tree

client-metadata.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@
33
"redirect_uris": ["http://127.0.0.1:8080/callback"],
44
"grant_types": ["authorization_code", "refresh_token"],
55
"response_types": ["code"],
6-
"token_endpoint_auth_method": "none"
6+
"token_endpoint_auth_method": "none",
7+
"application_type": "native"
78
}

crates/rmcp/src/transport/auth.rs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ impl<'c> AsyncHttpClient<'c> for OAuthReqwestClient {
5858

5959
const DEFAULT_EXCHANGE_URL: &str = "http://localhost";
6060

61+
/// Default OIDC Dynamic Client Registration `application_type` (SEP-837)
62+
const DEFAULT_APPLICATION_TYPE: &str = "native";
63+
6164
/// Stored credentials for OAuth2 authorization
6265
#[derive(Clone, Serialize, Deserialize)]
6366
#[non_exhaustive]
@@ -423,6 +426,7 @@ pub struct OAuthClientConfig {
423426
pub client_secret: Option<String>,
424427
pub scopes: Vec<String>,
425428
pub redirect_uri: String,
429+
pub application_type: Option<String>,
426430
}
427431

428432
impl OAuthClientConfig {
@@ -432,6 +436,7 @@ impl OAuthClientConfig {
432436
client_secret: None,
433437
scopes: Vec::new(),
434438
redirect_uri: redirect_uri.into(),
439+
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
435440
}
436441
}
437442

@@ -444,6 +449,12 @@ impl OAuthClientConfig {
444449
self.scopes = scopes;
445450
self
446451
}
452+
453+
/// Set the OIDC Dynamic Client Registration `application_type` (SEP-837), e.g. `"native"` or `"web"`
454+
pub fn with_application_type(mut self, application_type: impl Into<String>) -> Self {
455+
self.application_type = Some(application_type.into());
456+
self
457+
}
447458
}
448459

449460
// add type aliases for oauth2 types
@@ -613,6 +624,8 @@ pub struct AuthorizationManager {
613624
www_auth_scopes: RwLock<Vec<String>>,
614625
/// scopes_supported from protected resource metadata (RFC 9728)
615626
resource_scopes: RwLock<Vec<String>>,
627+
/// OIDC Dynamic Client Registration `application_type` (SEP-837)
628+
application_type: Option<String>,
616629
}
617630

618631
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -624,6 +637,8 @@ pub(crate) struct ClientRegistrationRequest {
624637
pub response_types: Vec<String>,
625638
#[serde(skip_serializing_if = "Option::is_none")]
626639
pub scope: Option<String>,
640+
#[serde(skip_serializing_if = "Option::is_none")]
641+
pub application_type: Option<String>,
627642
}
628643

629644
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -707,6 +722,7 @@ impl AuthorizationManager {
707722
scope_upgrade_config: ScopeUpgradeConfig::default(),
708723
www_auth_scopes: RwLock::new(Vec::new()),
709724
resource_scopes: RwLock::new(Vec::new()),
725+
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
710726
};
711727

712728
Ok(manager)
@@ -800,6 +816,11 @@ impl AuthorizationManager {
800816
return Err(AuthError::NoAuthorizationSupport);
801817
}
802818

819+
// SEP-837: only override application_type when the config sets one
820+
if let Some(application_type) = &config.application_type {
821+
self.application_type = Some(application_type.clone());
822+
}
823+
803824
let metadata = self.metadata.as_ref().unwrap();
804825

805826
let auth_url = AuthUrl::new(metadata.authorization_endpoint.clone())
@@ -890,6 +911,7 @@ impl AuthorizationManager {
890911
};
891912
self.validate_server_metadata("code")?;
892913

914+
let application_type = self.application_type.clone();
893915
let registration_request = ClientRegistrationRequest {
894916
client_name: name.to_string(),
895917
redirect_uris: vec![redirect_uri.to_string()],
@@ -904,6 +926,7 @@ impl AuthorizationManager {
904926
} else {
905927
Some(scopes.join(" "))
906928
},
929+
application_type: application_type.clone(),
907930
};
908931

909932
let response = match self
@@ -958,6 +981,7 @@ impl AuthorizationManager {
958981
client_secret: reg_response.client_secret.filter(|s| !s.is_empty()),
959982
redirect_uri: redirect_uri.to_string(),
960983
scopes: scopes.iter().map(|s| s.to_string()).collect(),
984+
application_type,
961985
};
962986

963987
self.configure_client(config.clone())?;
@@ -972,6 +996,8 @@ impl AuthorizationManager {
972996
client_secret: None,
973997
scopes: vec![],
974998
redirect_uri: self.base_url.to_string(),
999+
// keep the manager's current application_type
1000+
application_type: None,
9751001
};
9761002
self.configure_client(config)
9771003
}
@@ -2140,12 +2166,14 @@ impl AuthorizationSession {
21402166
client_metadata_url
21412167
)));
21422168
}
2143-
// SEP-991: URL-based Client IDs - use URL as client_id directly
2169+
// SEP-991: URL-based Client IDs - use URL as client_id directly.
2170+
// SEP-837: match the hosted client-metadata.json application_type ("native")
21442171
OAuthClientConfig {
21452172
client_id: client_metadata_url.to_string(),
21462173
client_secret: None,
21472174
scopes: scopes.iter().map(|s| s.to_string()).collect(),
21482175
redirect_uri: redirect_uri.to_string(),
2176+
application_type: Some(DEFAULT_APPLICATION_TYPE.to_string()),
21492177
}
21502178
} else {
21512179
// Fallback to dynamic registration
@@ -3117,6 +3145,7 @@ mod tests {
31173145
client_secret: Some("my-secret".to_string()),
31183146
scopes: vec![],
31193147
redirect_uri: "http://localhost/callback".to_string(),
3148+
application_type: None,
31203149
}
31213150
}
31223151

@@ -3678,6 +3707,7 @@ mod tests {
36783707
token_endpoint_auth_method: "none".to_string(),
36793708
response_types: vec!["code".to_string()],
36803709
scope: Some("read write".to_string()),
3710+
application_type: None,
36813711
};
36823712
let json = serde_json::to_value(&req).unwrap();
36833713
assert_eq!(json["scope"], "read write");
@@ -3692,11 +3722,59 @@ mod tests {
36923722
token_endpoint_auth_method: "none".to_string(),
36933723
response_types: vec!["code".to_string()],
36943724
scope: None,
3725+
application_type: None,
36953726
};
36963727
let json = serde_json::to_value(&req).unwrap();
36973728
assert!(!json.as_object().unwrap().contains_key("scope"));
36983729
}
36993730

3731+
// -- ClientRegistrationRequest application_type (SEP-837) --
3732+
3733+
#[test]
3734+
fn client_registration_request_includes_application_type_when_present() {
3735+
let req = super::ClientRegistrationRequest {
3736+
client_name: "test".to_string(),
3737+
redirect_uris: vec!["http://localhost/callback".to_string()],
3738+
grant_types: vec!["authorization_code".to_string()],
3739+
token_endpoint_auth_method: "none".to_string(),
3740+
response_types: vec!["code".to_string()],
3741+
scope: None,
3742+
application_type: Some("native".to_string()),
3743+
};
3744+
let json = serde_json::to_value(&req).unwrap();
3745+
assert_eq!(json["application_type"], "native");
3746+
}
3747+
3748+
#[test]
3749+
fn client_registration_request_omits_application_type_when_none() {
3750+
let req = super::ClientRegistrationRequest {
3751+
client_name: "test".to_string(),
3752+
redirect_uris: vec!["http://localhost/callback".to_string()],
3753+
grant_types: vec!["authorization_code".to_string()],
3754+
token_endpoint_auth_method: "none".to_string(),
3755+
response_types: vec!["code".to_string()],
3756+
scope: None,
3757+
application_type: None,
3758+
};
3759+
let json = serde_json::to_value(&req).unwrap();
3760+
assert!(!json.as_object().unwrap().contains_key("application_type"));
3761+
}
3762+
3763+
// -- OAuthClientConfig application_type (SEP-837) --
3764+
3765+
#[test]
3766+
fn oauth_client_config_defaults_application_type_to_native() {
3767+
let config = super::OAuthClientConfig::new("client-id", "http://127.0.0.1:8080/callback");
3768+
assert_eq!(config.application_type.as_deref(), Some("native"));
3769+
}
3770+
3771+
#[test]
3772+
fn oauth_client_config_with_application_type_overrides_default() {
3773+
let config = super::OAuthClientConfig::new("client-id", "https://app.example.com/callback")
3774+
.with_application_type("web");
3775+
assert_eq!(config.application_type.as_deref(), Some("web"));
3776+
}
3777+
37003778
// -- client credentials (SEP-1046) --
37013779

37023780
#[tokio::test]

0 commit comments

Comments
 (0)