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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ runs them in separate server and client steps with
`conformance/expected-failures-extensions.yaml`.

- SEP-2663 Tasks server: 9 expected failures; `tasks-status-notifications` is currently skipped by the upstream harness; tracked in #868
- Client extensions: `auth/client-credentials-basic` passes; `auth/client-credentials-jwt` and `auth/enterprise-managed-authorization` are expected failures
- Client extensions: `auth/client-credentials-basic` and `auth/client-credentials-jwt` pass; `auth/enterprise-managed-authorization` is an expected failure

### Spec features without conformance scenarios

Expand Down Expand Up @@ -110,7 +110,7 @@ These extension scenarios are tracked but do not count toward tier advancement:

| Scenario | Tag | Status |
|---|---|---|
| `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error |
| `auth/client-credentials-jwt` | extension | ✅ Passed |
| `auth/client-credentials-basic` | extension | ✅ Passed |
| `auth/enterprise-managed-authorization` | extension | ❌ Failed — scenario is not implemented by the conformance client |
| `tasks-*` | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped scenario |
3 changes: 1 addition & 2 deletions conformance/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ rmcp = { path = "../crates/rmcp", features = [
"client",
"elicitation",
"auth",
"auth-client-credentials-jwt",
"request-state",
"transport-streamable-http-server",
"transport-streamable-http-client-reqwest",
Expand All @@ -33,5 +34,3 @@ anyhow = "1"
reqwest = { version = "0.13", features = ["json"] }
urlencoding = "2"
url = "2"
p256 = { version = "0.14", features = ["ecdsa"] }
base64 = "0.22"
1 change: 0 additions & 1 deletion conformance/expected-failures-extensions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,4 @@ server: []

client:
# Informational OAuth extension scenarios.
- auth/client-credentials-jwt
- auth/enterprise-managed-authorization
142 changes: 36 additions & 106 deletions conformance/src/bin/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use rmcp::{
service::RequestContext,
transport::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{AuthorizationCallback, AuthorizationRequest, InMemoryCredentialStore, OAuthState},
auth::{
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState,
},
streamable_http_client::StreamableHttpClientTransportConfig,
},
};
Expand Down Expand Up @@ -651,49 +654,43 @@ async fn run_client_credentials_jwt(
) -> anyhow::Result<()> {
let client_id = ctx
.client_id
.as_deref()
.unwrap_or("conformance-test-client");
let _pem = ctx
.clone()
.unwrap_or_else(|| "conformance-test-client".to_string());
let signing_key = ctx
.private_key_pem
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?;
let _alg = ctx
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?
.as_bytes()
.to_vec();
let signing_algorithm = match ctx
.signing_algorithm
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?;

// Discover metadata to get token endpoint
let mut manager = AuthorizationManager::new(server_url).await?;
let metadata = manager.discover_metadata().await?;
let token_endpoint = metadata.token_endpoint.clone();
manager.set_metadata(metadata);

// Build JWT assertion
// Parse the PEM private key
let key = openssl_free_ec_sign(_pem, client_id, &token_endpoint)?;

let http = reqwest::Client::new();
let form_body = format!(
"grant_type=client_credentials&client_assertion_type={}&client_assertion={}",
urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
urlencoding::encode(&key),
);
let resp = http
.post(&token_endpoint)
.header("content-type", "application/x-www-form-urlencoded")
.body(form_body)
.send()
.await?;

let token_resp: serde_json::Value = resp.json().await?;
let access_token = token_resp["access_token"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("No access_token: {}", token_resp))?;
.ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?
{
"RS256" => JwtSigningAlgorithm::RS256,
"RS384" => JwtSigningAlgorithm::RS384,
"RS512" => JwtSigningAlgorithm::RS512,
"ES256" => JwtSigningAlgorithm::ES256,
"ES384" => JwtSigningAlgorithm::ES384,
algorithm => anyhow::bail!("Unsupported signing_algorithm: {algorithm}"),
};
let config = ClientCredentialsConfig::PrivateKeyJwt {
client_id,
signing_key,
signing_algorithm,
token_endpoint_audience: None,
scopes: vec![],
resource: Some(server_url.to_string()),
};
let mut oauth_state = OAuthState::new(server_url, None).await?;
oauth_state.authenticate_client_credentials(config).await?;
let manager = oauth_state
.into_authorization_manager()
.ok_or_else(|| anyhow::anyhow!("Client credentials flow did not authorize"))?;

let transport = StreamableHttpClientTransport::with_client(
reqwest::Client::default(),
StreamableHttpClientTransportConfig::with_uri(server_url)
.auth_header(access_token.to_string()),
AuthClient::new(reqwest::Client::default(), manager),
StreamableHttpClientTransportConfig::with_uri(server_url),
);

let client = BasicClientHandler.serve(transport).await?;
Expand All @@ -709,73 +706,6 @@ async fn run_client_credentials_jwt(
Ok(())
}

/// Minimal ES256 JWT signing without heavy deps.
/// We use ring or pure-Rust approach. For simplicity, use the p256 + base64 crates
/// that are already transitive deps of oauth2.
fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::Result<String> {
use std::time::{SystemTime, UNIX_EPOCH};

// Decode PEM → DER
let pem_body = pem
.lines()
.filter(|l| !l.starts_with("-----"))
.collect::<String>();
let der = base64_decode(&pem_body)?;

// Parse PKCS#8 DER to get the raw EC private key bytes
// PKCS#8 for EC P-256: the raw 32-byte key is at the end of the structure
let raw_key = extract_ec_private_key(&der)?;

let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let header = base64url_encode(br#"{"alg":"ES256","typ":"JWT"}"#);
let payload_json = serde_json::json!({
"iss": client_id,
"sub": client_id,
"aud": audience,
"iat": now,
"exp": now + 300,
"jti": format!("jti-{}", now),
});
let payload = base64url_encode(payload_json.to_string().as_bytes());
let signing_input = format!("{}.{}", header, payload);

// Sign with p256
let secret_key = p256::ecdsa::SigningKey::from_slice(raw_key.as_slice())
.map_err(|e| anyhow::anyhow!("Invalid EC key: {}", e))?;
use p256::ecdsa::signature::Signer;
let sig: p256::ecdsa::Signature = secret_key.sign(signing_input.as_bytes());
let sig_bytes = sig.to_bytes();
let sig_b64 = base64url_encode(&sig_bytes);

Ok(format!("{}.{}", signing_input, sig_b64))
}

fn base64url_encode(data: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data)
}

fn base64_decode(s: &str) -> anyhow::Result<Vec<u8>> {
use base64::Engine;
Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?)
}

/// Extract the raw 32-byte EC private key from a PKCS#8 DER blob.
fn extract_ec_private_key(der: &[u8]) -> anyhow::Result<Vec<u8>> {
// PKCS#8 wraps an ECPrivateKey. We look for the octet string containing
// the 32-byte private key. A simple heuristic: find 0x04 0x20 (OCTET STRING, len 32)
// followed by exactly 32 bytes that form the key.
// More robust: parse ASN.1. But for conformance testing this suffices.
for i in 0..der.len().saturating_sub(33) {
if der[i] == 0x04 && der[i + 1] == 0x20 && i + 34 <= der.len() {
return Ok(der[i + 2..i + 34].to_vec());
}
}
Err(anyhow::anyhow!(
"Could not extract 32-byte EC private key from PKCS#8 DER"
))
}

/// Cross-app access flow (SEP-1046 extension).
async fn run_cross_app_access_client(
server_url: &str,
Expand Down
4 changes: 4 additions & 0 deletions crates/rmcp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **BREAKING**: rename `StreamableHttpServerConfig::stateful_mode` to `legacy_session_mode` (and the builder `with_stateful_mode` to `with_legacy_session_mode`) to clarify that the option only affects legacy protocol versions (`< 2026-07-28`); per SEP-2567 the `2026-07-28` draft version is always served statelessly ([#999](https://github.com/modelcontextprotocol/rust-sdk/pull/999))

### Fixed

- use the authorization server issuer as the default `private_key_jwt` audience

## [2.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v2.1.0...rmcp-v2.2.0) - 2026-07-08

### Added
Expand Down
2 changes: 1 addition & 1 deletion crates/rmcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pastey = { version = "0.2.0", optional = true }
# oauth2 support
oauth2 = { version = "5.0", optional = true, default-features = false }
# JWT signing for client credentials (private_key_jwt)
jsonwebtoken = { version = "10", optional = true }
jsonwebtoken = { version = "10", optional = true, features = ["aws_lc_rs"] }

# for auto generate schema
schemars = { version = "1.0", optional = true, features = ["chrono04"] }
Expand Down
Loading